././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.7289271 photutils-2.2.0/0000755000175100001660000000000014755160634013164 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/.flake80000644000175100001660000000005714755160622014336 0ustar00runnerdocker[flake8] max-line-length = 79 exclude = extern ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6749265 photutils-2.2.0/.github/0000755000175100001660000000000014755160634014524 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/.github/dependabot.yml0000644000175100001660000000100214755160622017342 0ustar00runnerdocker# Keep dependencies updated with Dependabot version updates # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: # Maintain dependencies for GitHub Actions - package-ecosystem: "github-actions" directory: ".github/workflows/" schedule: interval: "monthly" groups: actions: patterns: - "*" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6759264 photutils-2.2.0/.github/workflows/0000755000175100001660000000000014755160634016561 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/.github/workflows/ci_cron_daily.yml0000644000175100001660000000465314755160622022107 0ustar00runnerdockername: Daily Cron Tests on: schedule: # run at 6am UTC on Tue-Fri (complete tests are run every Monday) - cron: '0 6 * * 2-5' pull_request: # We also want this workflow triggered if the 'Daily CI' label is added # or present when PR is updated types: - synchronize - labeled push: tags: - '*' workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: TOXARGS: '-v' permissions: contents: read jobs: tests: if: (github.repository == 'astropy/photutils' && (github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'Daily CI'))) name: ${{ matrix.prefix }} ${{ matrix.os }}, ${{ matrix.tox_env }} runs-on: ${{ matrix.os }} continue-on-error: ${{ matrix.allow_failure }} strategy: matrix: include: - os: ubuntu-latest python: '3.12' tox_env: 'linkcheck' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.13' tox_env: 'py313-test-devdeps' toxposargs: --remote-data=any allow_failure: true prefix: '(Allowed failure)' steps: - name: Check out repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.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@13ce06bfc6bbe3ecf90edbbf1bc32fe5978ca1d3 # v5.3.1 with: files: ./coverage.xml verbose: true ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/.github/workflows/ci_cron_weekly.yml0000644000175100001660000001076414755160622022305 0ustar00runnerdockername: Weekly Cron Tests on: schedule: # run every Monday at 5am UTC - cron: '0 5 * * 1' pull_request: # We also want this workflow triggered if the 'Weekly CI' label is added # or present when PR is updated types: - synchronize - labeled push: tags: - '*' workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: TOXARGS: '-v' IS_CRON: 'true' permissions: contents: read jobs: tests: if: (github.repository == 'astropy/photutils' && (github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'Weekly CI'))) name: ${{ matrix.os }}, ${{ matrix.tox_env }} runs-on: ${{ matrix.os }} continue-on-error: ${{ matrix.allow_failure }} strategy: fail-fast: false matrix: include: - os: ubuntu-latest python: '3.12' tox_env: 'py312-test-alldeps-devinfra' allow_failure: false steps: - name: Check out repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 with: python-version: ${{ matrix.python }} - name: Install base dependencies run: python -m pip install --upgrade pip setuptools tox - name: Print Python, pip, setuptools, and tox versions run: | python -c "import sys; print(f'Python {sys.version}')" python -c "import pip; print(f'pip {pip.__version__}')" python -c "import setuptools; print(f'setuptools {setuptools.__version__}')" python -c "import tox; print(f'tox {tox.__version__}')" - name: Run tests run: python -m tox -e ${{ matrix.tox_env }} -- ${{ matrix.toxposargs }} test_more_architectures: # The following architectures are emulated and are therefore slow, so # we include them just in the weekly cron. These also serve as a test # of using system libraries and using pytest directly. runs-on: ubuntu-latest name: More architectures if: (github.repository == 'astropy/photutils' && (github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'Arch CI'))) env: ARCH_ON_CI: ${{ matrix.arch }} strategy: fail-fast: false matrix: include: - arch: s390x - arch: ppc64le - arch: armv7 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - uses: uraimo/run-on-arch-action@5397f9e30a9b62422f302092631c99ae1effcd9e # v2.8.1 name: Run tests id: build with: arch: ${{ matrix.arch }} distro: ubuntu_rolling shell: /bin/bash env: | ARCH_ON_CI: ${{ env.ARCH_ON_CI }} IS_CRON: ${{ env.IS_CRON }} install: | apt-get update -q -y apt-get install -q -y --no-install-recommends \ git \ g++ \ pkg-config \ python3 \ python3-astropy \ python3-erfa \ python3-extension-helpers \ python3-numpy \ python3-pytest-astropy \ python3-setuptools-scm \ python3-scipy \ python3-skimage \ python3-sklearn \ python3-venv \ python3-wheel \ wcslib-dev run: | uname -a echo "LONG_BIT="$(getconf LONG_BIT) python3 -m venv --system-site-packages tests source tests/bin/activate # cython and pyerfa versions in ubuntu repos are too old currently pip install -U cython pip install -U --no-build-isolation pyerfa ASTROPY_USE_SYSTEM_ALL=1 pip3 install -v --no-build-isolation -e .[test] pip3 list python3 -m pytest ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/.github/workflows/ci_tests.yml0000644000175100001660000000750614755160622021126 0ustar00runnerdockername: CI Tests on: push: branches: - main tags: - '*' pull_request: schedule: # run every Monday at 6am UTC - cron: '0 6 * * 1' workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: TOXARGS: '-v' permissions: contents: read jobs: tests: name: ${{ matrix.prefix }} ${{ matrix.os }}, ${{ matrix.tox_env }} runs-on: ${{ matrix.os }} continue-on-error: ${{ matrix.allow_failure }} strategy: matrix: include: - os: ubuntu-latest python: '3.11' tox_env: 'py311-test-oldestdeps' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.11' tox_env: 'py311-test-alldeps' allow_failure: false prefix: '' - os: macos-latest python: '3.12' tox_env: 'py312-test-alldeps' allow_failure: false prefix: '' - os: macos-14 python: '3.12' tox_env: 'py312-test-alldeps' allow_failure: false prefix: 'M1' - os: windows-latest python: '3.12' tox_env: 'py312-test-alldeps' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.12' tox_env: 'py312-test' toxposargs: --remote-data=any allow_failure: false prefix: '' - os: ubuntu-latest python: '3.12' tox_env: 'py312-test-alldeps-cov' toxposargs: --remote-data=any allow_failure: false prefix: '' # cannot use alldeps: rasterio does not have a arm64 wheel - os: ubuntu-24.04-arm python: '3.12' tox_env: 'py312-test' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.12' tox_env: 'codestyle' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.12' tox_env: 'pep517' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.12' tox_env: 'bandit' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.13' tox_env: 'py313-test-alldeps' allow_failure: false prefix: '' - os: ubuntu-latest python: '3.13' tox_env: 'py313-test-devdeps' toxposargs: --remote-data=any allow_failure: true prefix: '(Allowed failure)' steps: - name: Check out repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.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@13ce06bfc6bbe3ecf90edbbf1bc32fe5978ca1d3 # v5.3.1 with: files: ./coverage.xml verbose: true ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/.github/workflows/codeql.yml0000644000175100001660000000621114755160622020550 0ustar00runnerdocker# For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: [ "main" ] pull_request: # The branches below must be a subset of the branches above branches: [ "main" ] schedule: - cron: '40 5 * * 4' permissions: contents: read jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'python' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Use only 'java' to analyze code written in Java, Kotlin or both # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 # 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}}" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/.github/workflows/publish.yml0000644000175100001660000000425414755160622020754 0ustar00runnerdockername: Wheel building on: schedule: # run every day at 4:30am UTC - cron: '30 4 * * *' pull_request: # We also want this workflow triggered if the 'Build all wheels' # label is added or present when PR is updated types: - synchronize - labeled push: branches: - '*' tags: - '*' - '!*dev*' - '!*pre*' - '!*post*' workflow_dispatch: permissions: contents: read jobs: build_and_publish: # This job builds the wheels and publishes them to PyPI for all # tags, except those ending in ".dev". For PRs with the "Build all # wheels" label, wheels are built, but are not uploaded to PyPI. permissions: contents: none uses: OpenAstronomy/github-actions-workflows/.github/workflows/publish.yml@8c0fde6f7e926df6ed7057255d29afa9c1ad5320 # v1.16.0 if: (github.repository == 'astropy/photutils' && (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'Build all wheels'))) with: # We upload to PyPI for all tag pushes, except tags ending in .dev upload_to_pypi: ${{ startsWith(github.ref, 'refs/tags/') && !endsWith(github.ref, '.dev') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} test_extras: test test_command: pytest -p no:warnings --pyargs photutils targets: | # Linux wheels - cp311-manylinux_x86_64 - cp312-manylinux_x86_64 - cp313-manylinux_x86_64 # MacOS X wheels - cp311*macosx_x86_64 - cp312*macosx_x86_64 - cp313*macosx_x86_64 - cp311*macosx_arm64 - cp312*macosx_arm64 - cp313*macosx_arm64 # Windows wheels - cp311*win_amd64 - cp312*win_amd64 - cp313*win_amd64 # Developer wheels upload_to_anaconda: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }} anaconda_user: astropy anaconda_package: photutils anaconda_keep_n_latest: 10 secrets: pypi_token: ${{ secrets.pypi_token }} anaconda_token: ${{ secrets.anaconda_token }} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/.gitignore0000644000175100001660000000130714755160622015152 0ustar00runnerdocker# Compiled files *.py[cod] *.a *.o *.so __pycache__ # Ignore .c files by default to avoid including generated code. If you want to # add a non-generated .c extension, use `git add -f filename.c`. *.c # Other generated files */version.py */cython_version.py MANIFEST htmlcov .coverage* .ipynb_checkpoints .pytest_cache # Sphinx docs/_build docs/api # Packages/installer info *.egg *.egg-info dist build eggs .eggs parts bin var sdist develop-eggs .installed.cfg distribute-*.tar.gz pip-wheel-metadata # Other .cache .tox .tmp .*.sw[op] *~ # Eclipse editor project files .project .pydevproject .settings # PyCharm editor project files .idea # Visual Studio Code project files .vscode # Mac OSX .DS_Store ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/.pre-commit-config.yaml0000644000175100001660000000726614755160622017455 0ustar00runnerdockerci: autofix_prs: false autoupdate_schedule: 'monthly' repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.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.9.4" hooks: - id: ruff args: ["--fix", "--show-fixes"] - repo: https://github.com/scientific-python/cookie rev: 2025.01.22 hooks: - id: sp-repo-review - repo: https://github.com/pycqa/isort rev: 6.0.0 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.1.1 hooks: - id: flake8 args: ["--ignore", "E501,W503"] - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: - id: codespell args: ["--write-changes", "--ignore-words-list", "exten, conver, fom"] additional_dependencies: - tomli - repo: https://github.com/numpy/numpydoc rev: v1.8.0 hooks: - id: numpydoc-validation exclude: "extern/" # temporarily use commit from master branch until the next release # see https://github.com/PyCQA/docformatter/pull/287 - repo: https://github.com/PyCQA/docformatter rev: 06907d0267368b49b9180eed423fae5697c1e909 hooks: - id: docformatter additional_dependencies: [tomli] args: [--in-place, --config, ./pyproject.toml] exclude: "extern/" # - repo: https://github.com/MarcoGorelli/absolufy-imports # rev: v0.3.1 # hooks: # - id: absolufy-imports ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/.pycodestyle0000644000175100001660000000006414755160622015526 0ustar00runnerdocker[pycodestyle] max-line-length = 79 exclude = extern ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/.readthedocs.yaml0000644000175100001660000000070214755160622016407 0ustar00runnerdockerversion: 2 build: os: ubuntu-22.04 apt_packages: - graphviz tools: python: "3.12" jobs: post_checkout: - git fetch --shallow-since=2023-05-01 || true pre_install: - git update-index --assume-unchanged docs/conf.py sphinx: builder: html configuration: docs/conf.py fail_on_warning: true python: install: - method: pip path: . extra_requirements: - docs - all formats: [] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/CHANGES.rst0000644000175100001660000032212014755160622014763 0ustar00runnerdocker2.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 columns 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 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 a issue with the source labels after source deblending when using ``relabel=False``. [#1988] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - The ``xcenter`` and ``ycenter`` columns in the table returned by ``aperture_photometry`` no longer have (pixel) units for consistency with other tools. [#1993] - ``photutils.detection`` - When ``exclude_border`` is set to ``True`` in the ``DAOStarFinder`` and ``StarFinder`` classes, the excluded border region can be different along the x and y edges if the kernel shape is rectangular. [#1957] - Detected sources that match interval ends for sharpness, roundness, and maximum peak values (``sharplo``, ``sharphi``, ``roundlo``, ``roundhi``, and ``peakmax``) are now included in the returned table of detected sources by ``DAOStarFinder`` and ``IRAFStarFinder``. [#1978] - Detected sources that match the maximum peak value (``peakmax``) are now included in the returned table of detected sources by ``StarFinder``. [#1990] - ``photutils.morphology`` - The ``gini`` function now returns zero instead of NaN if the (unmasked) data values sum to zero. [#1979] - ``photutils.psf`` - The ``'viridis'`` color map is now the default in the ``GriddedPSFModel`` ``plot_grid`` method when ``deltas=True``. [#1954] - The ``GriddedPSFModel`` ``plot_grid`` color bar now matches the height of the displayed image. [#1955] 2.0.2 (2024-10-24) ------------------ Bug Fixes ^^^^^^^^^ - Due to an upstream bug in ``bottleneck`` with ``float32`` arrays, ``bottleneck`` nan-functions are now used internally only for ``float64`` arrays. Performance may be impacted for computations involving arrays with dtype other than ``float64``. Affected functions are used in the ``aperture``, ``background``, ``detection``, ``profiles``, ``psf``, and ``segmentation`` subpackages. This change has no impact if ``bottleneck`` is not installed. - ``photutils.background`` - Fixed a bug in ``Background2D`` where an error would be raised when using the ``BkgIDWInterpolator`` interpolator when any mesh was excluded, e.g., due to an input mask. [#1940] - ``photutils.detection`` - Fixed a bug in the star finders (``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinder``) when ``exclude_border=True``. Also, fixed an issue with ``exclude_border=True`` where if all sources were in the border region then an error would be raised. [#1943] 2.0.1 (2024-10-16) ------------------ Bug Fixes ^^^^^^^^^ - ``photutils.background`` - Fixed a bug in ``SExtractorBackground`` where the dimensionality of the returned value would not be preserved if the output was a single value. [#1934] - Fixed an issue in ``Background2D`` where if the ``box_size`` equals the input array shape the input data array could be modified. [#1935] 2.0.0 (2024-10-14) ------------------ General ^^^^^^^ - The ``regions`` package is now an optional dependency. [#1813] - The minimum required Astropy is now 5.3. [#1839] - SciPy is now a required dependency. [#1880] - The minimum required SciPy is now 1.10. [#1880] - The minimum required NumPy is now 1.24. [#1881] - The minimum required Matplotlib is now 3.7. [#1881] - The minimum required GWCS is now 0.19. [#1881] - Importing tools from all subpackages now requires including the subpackage name. Also, PSF matching tools must now be imported from ``photutils.psf.matching`` instead of ``photutils.psf``. [#1879, #1904] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - The metadata in the tables generated by ``aperture_photometry`` and ``ApertureStats`` now include the aperture name and shape parameters. [#1849] - ``aperture_photometry`` and ``ApertureStats`` now accept supported ``regions.Region`` objects, i.e., those corresponding to circular, elliptical, and rectangular apertures. [#1813, #1852] - A new ``region_to_aperture`` convenience function has been added to convert supported ``regions.Region`` objects to ``Aperture`` objects. [#1813, #1852] - ``photutils.background`` - The ``Background2D`` class has been refactored to significantly reduce its memory usage. In some cases, it is also significantly faster. [#1870, #1872, #1873] - A new ``npixels_mesh`` property was added to ``Background2D`` that gives a 2D array of the number of pixels used to compute the statistics in the low-resolution grid. [#1870] - A new ``npixels_map`` property was added to ``Background2D`` that gives a 2D array of the number of pixels used to compute the statistics in each mesh, resized to the shape of the input data. [#1871] - ``photutils.centroids`` - ``Quantity`` arrays can now be input to ``centroid_1dg`` and ``centroid_2dg``. [#1861] - ``photutils.datasets`` - Added a new ``params_table_to_models`` function to create a list of models from a table of model parameters. [#1896] - ``photutils.psf`` - Added new ``xy_bounds`` keyword to ``PSFPhotometry`` and ``IterativePSFPhotometry`` to allow one to bound the x and y model parameters during the fitting. [#1805] - The ``extract_stars`` function can now accept ``NDData`` inputs with uncertainty types other than ``weights``. [#1821] - Added new ``GaussianPSF``, ``CircularGaussianPSF``, ``GaussianPRF``, ``CircularGaussianPRF``, and ``MoffatPSF`` PSF model classes. [#1838, #1898, #1918] - Added new ``AiryDiskPSF`` PSF model class. [#1843, #1918] - Added new ``CircularGaussianSigmaPRF`` PSF model class. [#1845, #1918] - The ``IntegratedGaussianPRF`` model now supports units. [#1838] - A new ``results`` attribute was added to ``PSFPhotometry`` to store the returned table of fit results. [#1858] - Added new ``fit_fwhm`` convenience function to estimate the FWHM of one or more sources in an image by fitting a circular 2D Gaussian PSF model. [#1859, #1887, #1899, #1918] - Added new ``fit_2dgaussian`` convenience function to fit a circular 2D Gaussian PSF to one or more sources in an image. [#1859, #1887, #1899] - Added new ``ImagePSF`` model class to represent a PSF model as an image. [#1890] - The ``GriddedPSFModel`` model now has a ``bounding_box`` method to return the bounding box of the model. [#1891] - The ``GriddedPSFModel`` class has been refactored to significantly improve its performance. In typical PSF photometry use cases, it is now about 4 times faster than previous versions. [#1903] - ``photutils.segmentation`` - Reduced the memory usage and improved the performance of source deblending with ``deblend_sources`` and ``SourceFinder``. [#1924, #1925, #1926] - Improved the accuracy of the progress bar in ``deblend_sources`` and ``SourceFinder`` when using multiprocessing. Also added the source ID label number to the progress bar. [#1925, 1926] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed a bug checking that the ``subpixels`` keyword is a strictly positive integer. [#1816] - ``photutils.datasets`` - Fixed an issue in ``make_model_image`` where if the ``bbox_factor`` was input and the model bounding box did not have a ``factor`` keyword then an error would be raised. [#1921] - ``photutils.detection`` - Fixed an issue where ``DAOStarFinder`` would not return any sources if the input ``threshold`` was set to zero due to the ``flux`` being non-finite. [#1882] - ``photutils.isophote`` - Fixed a bug in ``build_ellipse_model`` where if ``high_harmonics=True``, the harmonics were not correctly added to the model. [#1810] - ``photutils.psf`` - Fixed a bug in ``make_psf_model`` where if the input model had amplitude units, an error would be raised. [#1894] API Changes ^^^^^^^^^^^ - The ``sklearn`` version information has been removed from the meta attribute in output tables. ``sklearn`` was removed as an optional dependency in 1.13.0. [#1807] - ``photutils.background`` - The ``Background2D`` ``background_mesh`` and ``background_rms_mesh`` properties will have units if the input data has units. [#1870] - The ``Background2D`` ``edge_method`` keyword is now deprecated. When ``edge_method`` is eventually removed, the ``'pad'`` option will always be used. [#1870] - The ``Background2D`` ``background_mesh_masked``, ``background_rms_mesh_masked``, and ``mesh_nmasked`` properties are now deprecated. [#1870] - To reduce memory usage, ``Background2D`` no longer keeps a cached copy of the returned ``background`` and ``background_rms`` properties. [#1870] - The ``Background2D`` ``data``, ``mask``, ``total_mask``, ``nboxes``, ``box_npixels``, and ``nboxes_tot`` attributes have been removed. [#1870] - The ``BkgZoomInterpolator`` ``grid_mode`` keyword is now deprecated. When ``grid_mode`` is eventually removed, the `True` option will always be used. [#1870] - The ``Background2D`` ``background``, ``background_rms``, ``background_mesh``, and ``background_rms_mesh`` properties now have the same ``dtype`` as the input data. [#1922] - ``photutils.centroids`` - For consistency with other fitting functions (including PSF fitting), the ``centroid_1dg`` and ``centroid_2dg`` functions now fit only a 1D or 2D Gaussian model, respectively, excluding any constant component. The input data are required to be background-subtracted. [#1861] - The fitter used in ``centroid_1dg`` and ``centroid_2dg`` was changed from ``LevMarLSQFitter`` to ``TRFLSQFitter``. ``LevMarLSQFitter`` uses the legacy SciPy function ``scipy.optimize.leastsq``, which is no longer recommended. [#1917] - ``photutils.datasets`` - The deprecated ``make`` module has been removed. Instead of importing functions from ``photutils.datasets.make``, import functions from ``photutils.datasets``. [#1884] - The deprecated ``make_model_sources_image``, ``make_gaussian_prf_sources_image``, ``make_gaussian_sources_table``, ``make_test_psf_data``, ``make_random_gaussians_table``, and ``make_imagehdu`` functions have been removed. [#1884] - ``photutils.detection`` - The deprecated ``sky`` keyword in ``DAOStarFinder`` and ``IRAFStarFinder`` has been removed. Also, there will no longer be a ``sky`` column in the output table. [#1884] - The ``DAOStarFinder`` ``flux`` and ``mag`` columns were changed to give sensible values. Previously, the ``flux`` value was defined by the original DAOFIND algorithm as a measure of the intensity ratio of the amplitude of the best fitting Gaussian function at the object position to the detection threshold. A ``daofind_mag`` column was added for comparison to the original IRAF DAOFIND algorithm. [#1885] - ``photutils.isophote`` - The ``build_ellipse_model`` function now raises a ``ValueError`` if the input ``isolist`` is empty. [#1809] - ``photutils.profiles`` - The fitter used in ``RadialProfile`` to fit the profile with a Gaussian was changed from ``LevMarLSQFitter`` to ``TRFLSQFitter``. ``LevMarLSQFitter`` uses the legacy SciPy function ``scipy.optimize.leastsq``, which is no longer recommended. [#1899] - ``photutils.psf`` - The ``IntegratedGaussianPRF`` class now must be initialized using keyword-only arguments. [#1838] - The ``IntegratedGaussianPRF`` class has been moved to the new ``functional_models`` module. [#1838] - The ``models`` and ``griddedpsfmodel`` modules have been renamed to ``image_models`` and ``gridded_models``, respectively. [#1838] - The ``IntegratedGaussianPRF`` model class has been renamed to ``CircularGaussianPRF``. ``IntegratedGaussianPRF`` is now deprecated. [#1845] - Some PSF tools have moved to new modules. The ``PRFAdapter`` class and the ``make_psf_model`` and ``grid_from_epsfs`` functions have been moved to the new ``model_helpers`` module. The ``make_psf_model_image`` function has been moved to the new ``simulations`` module. It is recommended that all of these tools be imported from ``photutils.psf`` without using the submodule name. [#1854, #1901] - The ``PSFPhotometry`` ``fit_results`` attribute has been renamed to ``fit_info``. ``fit_results`` is now deprecated. [#1858] - The ``PRFAdapter`` class has been deprecated. Instead, use a ``ImagePSF`` model derived from the ``discretize_model`` function in ``astropy.convolution``. [#1865] - The ``FittableImageModel`` and ``EPSFModel`` classes have been deprecated. Instead, use the new ``ImagePSF`` model class. [#1890] - The default fitter for ``PSFPhotometry``, ``IterativePSFPhotometry``, and ``EPSFFitter`` was changed from ``LevMarLSQFitter`` to ``TRFLSQFitter``. ``LevMarLSQFitter`` uses the legacy SciPy function ``scipy.optimize.leastsq``, which is no longer recommended. [#1899] - ``psf_shape`` is now an optional keyword in the ``make_model_image`` and ``make_residual_image`` methods of ``PSFPhotometry`` and ``IterativePSFPhotometry``. The value defaults to using the model bounding box to define the shape and is required only if the PSF model does not have a bounding box attribute. [#1921] - ``photutils.psf.matching`` - PSF matching tools must now be imported from ``photutils.psf.matching`` instead of ``photutils.psf``. [#1904] - ``photutils.segmentation`` - The ``SegmentationImage`` ``relabel_consecutive``, ``resassign_label(s)``, ``keep_label(s)``, ``remove_label(s)``, ``remove_border_labels``, and ``remove_masked_labels`` methods now keep the original dtype of the segmentation image instead of always changing it to ``int`` (``int64``). [#1878, #1923] - The ``detect_sources`` and ``deblend_sources`` functions now return a ``SegmentationImage`` instance whose data dtype is ``np.int32`` instead of ``int`` (``int64``) unless more than (2**32 - 1) labels are needed. [#1878] 1.13.0 (2024-06-28) ------------------- General ^^^^^^^ - ``scikit-learn`` has been removed as an optional dependency. [#1774] New Features ^^^^^^^^^^^^ - ``photutils.datasets`` - Added a ``make_model_image`` function for generating simulated images with model sources. This function has more options and is significantly faster than the now-deprecated ``make_model_sources_image`` function. [#1759, #1790] - Added a ``make_model_params`` function to make a table of randomly generated model positions, fluxes, or other parameters for simulated sources. [#1766, #1796] - ``photutils.detection`` - The ``find_peaks`` function now supports input arrays with units. [#1743] - The ``Table`` returned from ``find_peaks`` now has an ``id`` column that contains unique integer IDs for each peak. [#1743] - The ``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinder`` classes now support input arrays with units. [#1746] - ``photutils.profiles`` - Added an ``unnormalize`` method to ``RadialProfile`` and ``CurveOfGrowth`` to return the profile to the state before any ``normalize`` calls were run. [#1732] - Added ``calc_ee_from_radius`` and ``calc_radius_from_ee`` methods to ``CurveOfGrowth``. [#1733] - ``photutils.psf`` - Added an ``include_localbkg`` keyword to the ``IterativePSFPhotometry`` ``make_model_image`` and ``make_residual_image`` methods. [#1756] - Added "x_fit", "xfit", "y_fit", "yfit", "flux_fit", and "fluxfit" as allowed column names in the ``init_params`` table input to the PSF photometry objects. [#1765] - Added a ``make_psf_model_image`` function to generate a simulated image from PSF models. [#1785, #1796] - ``PSFPhotometry`` now has a new ``fit_params`` attribute containing a table of the fit model parameters and errors. [#1789] - The ``PSFPhotometry`` and ``IterativePSFPhotometry`` ``init_params`` table now allows the user to input columns for model parameters other than x, y, and flux. The column names must match the parameter names in the PSF model. They can also be suffixed with either the "_init" or "_fit" suffix. [#1793] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed an issue in ``ApertureStats`` where in very rare cases the ``covariance`` calculation could take a long time. [#1788] - ``photutils.background`` - No longer warn about NaNs in the data if those NaNs are masked in ``coverage_mask`` passed to ``Background2D``. [#1729] - ``photutils.psf`` - Fixed an issue where ``IterativePSFPhotometry`` would fail if the input data was a ``Quantity`` array. [#1746] - Fixed the ``IntegratedGaussianPRF`` class ``bounding_box`` limits to always be symmetric. [#1754] - Fixed an issue where ``IterativePSFPhotometry`` could sometimes issue a warning when merging tables if ``mode='all'``. [#1761] - Fixed a bug where the first matching column in the ``init_params`` table was not used in ``PSFPhotometry`` and ``IterativePSFPhotometry``. [#1765] - Fixed an issue where ``IterativePSFPhotometry`` could sometimes raise an error about non-overlapping data. [#1778] - Fixed an issue with unit handling in ``PSFPhotometry`` and ``IterativePSFPhotometry``. [#1792] - Fixed an issue in ``IterativePSFPhotometry`` where the ``fit_results`` attribute was not cleared between repeated calls. [#1793] - ``photutils.segmentation`` - Fixed an issue in ``SourceCatalog`` where in very rare cases the ``covariance`` calculation could take a long time. [#1788] API Changes ^^^^^^^^^^^ - The ``photutils.test`` function has been removed. Instead use the ``pytest --pyargs photutils`` command. [#1725] - ``photutils.datasets`` - The ``photutils.datasets`` subpackage has been reorganized and the ``make`` module has been deprecated. Instead of importing functions from ``photutils.datasets.make``, import functions from ``photutils.datasets``. [#1726] - The ``make_model_sources_image`` function has been deprecated in favor of the new ``make_model_image`` function. The new function has more options and is significantly faster. [#1759] - The randomly-generated optional noise in the simulated example images ``make_4gaussians_image`` and ``make_100gaussians_image`` is now slightly different. The noise sigma is the same, but the pixel values differ. [#1760] - The ``make_gaussian_prf_sources_image`` function is now deprecated. Use the ``make_model_psf_image`` function or the new ``make_model_image`` function instead. [#1762] - The ``make_gaussian_sources_table`` function now includes an "id" column and always returns both ``'flux'`` and ``'amplitude'`` columns. [#1763] - The ``make_model_sources_table`` function now includes an "id" column. [#1764] - The ``make_gaussian_sources_table`` function is now deprecated. Use the ``make_model_sources_table`` function instead. [#1764] - The ``make_test_psf_data`` function is now deprecated. Use the new ``make_model_psf_image`` function instead. [#1785] - ``photutils.detection`` - The ``sky`` keyword in ``DAOStarFinder`` and ``IRAFStarFinder`` is now deprecated and will be removed in a future version. [#1747] - Sources that have non-finite properties (e.g., centroid, roundness, sharpness, etc.) are automatically excluded from the output table in ``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinder``. [#1750] - ``photutils.psf`` - ``PSFPhotometry`` and ``IterativePSFPhotometry`` now raise a ``ValueError`` if the input ``psf_model`` is not two-dimensional with ``n_inputs=2`` and ``n_outputs=1``. [#1741] - The ``IntegratedGaussianPRF`` class ``bounding_box`` is now a method instead of an attribute for consistency with Astropy models. The method has a ``factor`` keyword to scale the bounding box. The default scale factor is 5.5 times ``sigma``. [#1754] - The ``IterativePSFPhotometry`` ``make_model_image`` and ``make_residual_image`` methods no longer include the local background by default. This is a backwards-incompatible change. If the previous behavior is desired, set ``include_localbkg=True``. [#1756] - ``IterativePSFPhotometry`` will now only issue warnings after all iterations are completed. [#1767] - The ``IterativePSFPhotometry`` ``psfphot`` attribute has been removed. Instead, use the ``fit_results`` attribute, which contains a list of ``PSFPhotometry`` instances for each fit iteration. [#1771] - The ``group_size`` column has been moved to come immediately after the ``group_id`` column in the output table from ``PSFPhotometry`` and ``IterativePSFPhotometry``. [#1772] - The ``PSFPhotometry`` ``init_params`` table was moved from the ``fit_results`` dictionary to an attribute. [#1773] - Removed ``local_bkg``, ``psfcenter_indices``, ``fit_residuals``, ``npixfit``, and ``nmodels`` keys from the ``PSFPhotometry`` ``fit_results`` dictionary. [#1773] - Removed the deprecated ``BasicPSFPhotometry``, ``IterativelySubtractedPSFPhotometry``, ``DAOPhotPSFPhotometry``, ``DAOGroup``, ``DBSCANGroup``, and ``GroupStarsBase``, and ``NonNormalizable`` classes and the ``prepare_psf_model``, ``get_grouped_psf_model``, and ``subtract_psf`` functions. [#1774] - A ``ValueError`` is now raised if the shape of the ``error`` array does not match the ``data`` array when calling the PSF-fitting classes. [#1777] - The ``fit_param_errs`` key was removed from the ``PSFPhotometry`` ``fit_results`` dictionary. The fit parameter errors are now stored in the ``fit_params`` table. [#1789] - The ``cfit`` column in the ``PSFPhotometry`` and ``IterativePSFPhotometry`` result table will now be NaN for sources whose initial central pixel is masked. [#1789] 1.12.0 (2024-04-12) ------------------- General ^^^^^^^ - The minimum required Python is now 3.10. [#1719] - The minimum required NumPy is now 1.23. [#1719] - The minimum required SciPy is now 1.8. [#1719] - The minimum required scikit-image is now 0.20. [#1719] - The minimum required scikit-learn is now 1.1. [#1719] - The minimum required pytest-astropy is now 0.11. [#1719] - The minimum required sphinx-astropy is now 1.9. [#1719] - NumPy 2.0 is supported. Bug Fixes ^^^^^^^^^ - ``photutils.background`` - No longer warn about NaNs in the data if those NaNs are masked in ``mask`` passed to ``Background2D``. [#1712] API Changes ^^^^^^^^^^^ - ``photutils.utils`` - The default value for the ``ImageDepth`` ``mask_pad`` keyword is now set to 0. [#1714] 1.11.0 (2024-02-16) ------------------- New Features ^^^^^^^^^^^^ - ``photutils.psf`` - An ``init_params`` table is now included in the ``PSFPhotometry`` ``fit_results`` dictionary. [#1681] - Added an ``include_localbkg`` keyword to the ``PSFPhotometry`` ``make_model_image`` and ``make_residual_image`` methods. [#1691] - Significantly reduced the memory usage of PSF photometry when using a ``GriddedPSFModel`` PSF model. [#1679] - Added a ``mode`` keyword to ``IterativePSFPhotometry`` for controlling the fitting mode. [#1708] - ``photutils.datasets`` - Improved the performance of ``make_test_psf_data`` when generating random coordinates with a minimum separation. [#1668] - ``photutils.segmentation`` - The ``SourceFinder`` ``npixels`` keyword can now be a tuple corresponding to the values used for the source finder and source deblender, respectively. [#1688] - ``photutils.utils`` - Improved the performance of ``ImageDepth`` when generating random coordinates with a minimum separation. [#1668] Bug Fixes ^^^^^^^^^ - ``photutils.psf`` - Fixed an issue where PSF models produced by ``make_psf_model`` would raise an error with ``PSFPhotometry`` if the fit did not converge. [#1672] - Fixed an issue where ``GriddedPSFModel`` fixed model parameters were not respected when copying the model or fitting with the PSF photometry classes. [#1679] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - ``PixelAperture`` instances now raise an informative error message when ``positions`` is input as a ``zip`` object containing Astropy ``Quantity`` objects. [#1682] - ``photutils.psf`` - The ``GridddedPSFModel`` string representations now include the model ``flux``, ``x_0``, and ``y_0`` parameters. [#1680] - The ``PSFPhotometry`` ``make_model_image`` and ``make_residual_image`` methods no longer include the local background by default. This is a backwards-incompatible change. If the previous behavior is desired, set ``include_localbkg=True``. [#1703] - The PSF photometry ``finder_results`` attribute is now returned as a ``QTable`` instead of a list of ``QTable``. [#1704] - Deprecated the ``NonNormalizable`` custom warning class in favor of ``AstropyUserWarning``. [#1710] - ``photutils.segmentation`` - The ``SourceCatalog`` ``get_label`` and ``get_labels`` methods now raise a ``ValueError`` if any of the input labels are invalid. [#1694] 1.10.0 (2023-11-21) ------------------- General ^^^^^^^ - The minimum required Astropy is now 5.1. [#1627] New Features ^^^^^^^^^^^^ - ``photutils.datasets`` - Added a ``border_size`` keyword to ``make_test_psf_data``. [#1665] - Improved the generation of random PSF positions in ``make_test_psf_data``. [#1665] - ``photutils.detection`` - Added a ``min_separation`` keyword to ``DAOStarFinder`` and ``IRAFStarFinder``. [#1663] - ``photutils.morphology`` - Added a ``wcs`` keyword to ``data_properties``. [#1648] - ``photutils.psf`` - The ``GriddedPSFModel`` ``plot_grid`` method now returns a ``matplotlib.figure.Figure`` object. [#1653] - Added the ability for the ``GriddedPSFModel`` ``read`` method to read FITS files generated by WebbPSF. [#1654] - Added "flux_0" and "flux0" as allowed flux column names in the ``init_params`` table input to the PSF photometry objects. [#1656] - PSF models output from ``prepare_psf_model`` can now be input into the PSF photometry classes. [#1657] - Added ``make_psf_model`` function for making a PSF model from a 2D Astropy model. Compound models are also supported. [#1658] - The ``GriddedPSFModel`` oversampling can now be different in the x and y directions. The ``oversampling`` attribute is now stored as a 1D ``numpy.ndarray`` with two elements. [#1664] - ``photutils.segmentation`` - The ``SegmentationImage`` ``make_source_mask`` method now uses a much faster implementation of binary dilation. [#1638] - Added a ``scale`` keyword to the ``SegmentationImage.to_patches()`` method to scale the sizes of the polygon patches. [#1641, #1646] - Improved the ``SegmentationImage`` ``imshow`` method to ensure that labels are plotted with unique colors. [#1649] - Added a ``imshow_map`` method to ``SegmentationImage`` for plotting segmentation images with a small number of non-consecutive labels. [#1649] - Added a ``reset_cmap`` method to ``SegmentationImage`` for resetting the colormap to a new random colormap. [#1649] - ``photutils.utils`` - Improved the generation of random aperture positions in ``ImageDepth``. [#1666] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed an issue where the aperture ``plot`` method ``**kwargs`` were not reset to the default values when called multiple times. [#1655] - ``photutils.psf`` - Fixed a bug where ``SourceGrouper`` would fail if only one source was input. [#1617] - Fixed a bug in ``GriddedPSFModel`` ``plot_grid`` where the grid could be plotted incorrectly if the input ``xygrid`` was not sorted in y then x order. [#1661] - ``photutils.segmentation`` - Fixed an issue where ``deblend_sources`` and ``SourceFinder`` would raise an error if the ``contrast`` keyword was set to 1 (meaning no deblending). [#1636] - Fixed an issue where the vertices of the ``SegmentationImage`` ``polygons`` were shifted by 0.5 pixels in both x and y. [#1646] API Changes ^^^^^^^^^^^ - The metadata in output tables now contains a timestamp. [#1640] - The order of the metadata in a table is now preserved when writing to a file. [#1640] - ``photutils.psf`` - Deprecated the ``prepare_psf_model`` function. Use the new ``make_psf_model`` function instead. [#1658] - The ``GriddedPSFModel`` now stores the ePSF grid such that it is first sorted by y then by x. As a result, the order of the ``data`` and ``xygrid`` attributes may be different. [#1661] - The ``oversampling`` attribute is now stored as a 1D ``numpy.ndarray`` with two elements. [#1664] - A ``ValueError`` is raised if ``GriddedPSFModel`` is called with x and y arrays that have more than 2 dimensions. [#1662] - ``photutils.segmentation`` - Removed the deprecated ``kernel`` keyword from ``SourceCatalog``. [#1613] 1.9.0 (2023-08-14) ------------------ General ^^^^^^^ - The minimum required Python is now 3.9. [#1569] - The minimum required NumPy is now 1.22. [#1572] New Features ^^^^^^^^^^^^ - ``photutils.background`` - Added ``LocalBackground`` class for computing local backgrounds in a circular annulus aperture. [#1556] - ``photutils.datasets`` - Added new ``make_test_psf_data`` function. [#1558, #1582, #1585] - ``photutils.psf`` - Propagate measurement uncertainties in PSF fitting. [#1543] - Added new ``PSFPhotometry`` and ``IterativePSFPhotometry`` classes for performing PSF-fitting photometry. [#1558, #1559, #1563, #1566, #1567, #1581, #1586, #1590, #1594, #1603, #1604] - Added a new ``SourceGrouper`` class. [#1558, #1605] - Added a ``GriddedPSFModel`` ``fill_value`` attribute. [#1583] - Added a ``grid_from_epsfs`` function to make a ``GriddedPSFModel`` from ePSFs. [#1596] - Added a ``read`` method to ``GriddedPSFModel`` for reading "STDPSF" FITS files containing grids of ePSF models. [#1557] - Added a ``plot_grid`` method to ``GriddedPSFModel`` for plotting ePSF grids. [#1557] - Added a ``STDPSFGrid`` class for reading "STDPSF" FITS files containing grids of ePSF models and plotting the ePSF grids. [#1557] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed a bug in the validation of ``PixelAperture`` positions. [#1553] API Changes ^^^^^^^^^^^ - ``photutils.psf`` - Deprecated the PSF photometry classes ``BasicPSFPhotometry``, ``IterativelySubtractedPSFPhotometry``, and ``DAOPhotPSFPhotometry``. Use the new ``PSFPhotometry`` or ``IterativePSFPhotometry`` class instead. [#1578] - Deprecated the ``DAOGroup``, ``DBSCANGroup``, and ``GroupStarsBase`` classes. Use the new ``SourceGrouper`` class instead. [#1578] - Deprecated the ``get_grouped_psf_model`` and ``subtract_psf`` function. [#1578] 1.8.0 (2023-05-17) ------------------ General ^^^^^^^ - The minimum required Numpy is now 1.21. [#1528] - The minimum required Scipy is now 1.7.0. [#1528] - The minimum required Matplotlib is now 3.5.0. [#1528] - The minimum required scikit-image is now 0.19.0. [#1528] - The minimum required gwcs is now 0.18. [#1528] New Features ^^^^^^^^^^^^ - ``photutils.profiles`` - The ``RadialProfile`` and ``CurveOfGrowth`` radial bins can now be directly input, which also allows for non-uniform radial spacing. [#1540] Bug Fixes ^^^^^^^^^ - ``photutils.psf`` - Fixed an issue with the local model cache in ``GriddedPSFModel``, significantly improving performance. [#1536] API Changes ^^^^^^^^^^^ - Removed the deprecated ``axes`` keyword in favor of ``ax`` for consistency with other packages. [#1523] - ``photutils.aperture`` - Removed the ``ApertureStats`` ``unpack_nddata`` method. [#1537] - ``photutils.profiles`` - The API for defining the radial bins for the ``RadialProfile`` and ``CurveOfGrowth`` classes was changed. While the new API allows for more flexibility, unfortunately, it is not backwards-compatible. [#1540] - ``photutils.segmentation`` - Removed the deprecated ``kernel`` keyword from ``detect_sources`` and ``deblend_sources``. [#1524] - Deprecated the ``kernel`` keyword in ``SourceCatalog``. [#1525] - Removed the deprecated ``outline_segments`` method from ``SegmentationImage``. [#1526] - The ``SourceCatalog`` ``kron_params`` attribute is no longer returned as a ``ndarray``. It is returned as a ``tuple``. [#1531] 1.7.0 (2023-04-05) ------------------ General ^^^^^^^ - The ``rasterio`` and ``shapely`` packages are now optional dependencies. [#1509] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - Significantly improved the performance of ``aperture_photometry`` and the ``PixelAperture`` ``do_photometry`` method for large arrays. [#1485] - Significantly improved the performance of the ``PixelAperture`` ``area_overlap`` method, especially for large arrays. [#1490] - ``photutils.profiles`` - Added a new ``profiles`` subpackage containing ``RadialProfile`` and ``CurveOfGrowth`` classes. [#1494, #1496, #1498, #1499] - ``photutils.psf`` - Significantly improved the performance of evaluating and fitting ``GriddedPSFModel`` instances. [#1503] - ``photutils.segmentation`` - Added a ``size`` keyword to the ``SegmentationImage`` ``make_source_mask`` method. [#1506] - Significantly improved the performance of ``SegmentationImage`` ``make_source_mask`` when using square footprints for source dilation. [#1506] - Added the ``polygons`` property and ``to_patches`` and ``plot_patches`` methods to ``SegmentationImage``. [#1509] - Added ``polygon`` keyword to the ``Segment`` class. [#1509] Bug Fixes ^^^^^^^^^ - ``photutils.centroids`` - Fixed an issue where ``centroid_quadratic`` would sometimes fail if the input data contained NaNs. [#1495] - ``photutils.detection`` - Fixed an issue with the starfinders (``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinder``) where an exception was raised if ``exclude_border=True`` and there were no detections. [#1512]. - ``photutils.isophote`` - Fixed a bug where the upper harmonics (a3, a4, b3, and b4) had the incorrect sign. [#1501] - Fixed a bug in the calculation of the upper harmonic errors (a3_err, a4_err, b3_err, and b4_err). [#1501]. - ``photutils.psf`` - Fixed an issue where the PSF-photometry progress bar was not shown. [#1517] - Fixed an issue where all PSF uncertainties were excluded if the last star group had no covariance matrix. [#1519] - ``photutils.utils`` - Fixed a bug in the calculation of ``ImageCutout`` ``xyorigin`` when using the ``'partial'`` mode when the cutout extended beyond the right or top edge. [#1508] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - The ``ApertureStats`` ``local_bkg`` keyword can now be broadcast for apertures with multiple positions. [#1504] - ``photutils.centroids`` - The ``centroid_sources`` function will now raise an error if the cutout mask contains all ``True`` values. [#1516] - ``photutils.datasets`` - Removed the deprecated ``load_fermi_image`` function. [#1479] - ``photutils.psf`` - Removed the deprecated ``sandbox`` classes ``DiscretePRF`` and ``Reproject``. [#1479] - ``photutils.segmentation`` - Removed the deprecated ``make_source_mask`` function in favor of the ``SegmentationImage.make_source_mask`` method. [#1479] - The ``SegmentationImage`` ``imshow`` method now uses "nearest" interpolation instead of "none" to avoid rendering issues with some backends. [#1507] - The ``repr()`` notebook output for the ``Segment`` class now includes a SVG polygon representation of the segment if the ``rasterio`` and ``shapely`` packages are installed. [#1509] - Deprecated the ``SegmentationImage`` ``outline_segments`` method. Use the ``plot_patches`` method instead. [#1509] 1.6.0 (2022-12-09) ------------------ General ^^^^^^^ - Following NEP 29, the minimum required Numpy is now 1.20. [#1442] - The minimum required Matplotlib is now 3.3.0. [#1442] - The minimum required scikit-image is now 0.18.0. [#1442] - The minimum required scikit-learn is now 1.0. [#1442] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - The ``ApertureStats`` class now accepts astropy ``NDData`` objects as input. [#1409] - Improved the performance of aperture photometry by 10-25% (depending on the number of aperture positions). [#1438] - ``photutils.psf`` - Added a progress bar for fitting PSF photometry [#1426] - Added a ``subshape`` keyword to the PSF-fitting classes to define the shape over which the PSF is subtracted. [#1477] - ``photutils.segmentation`` - Added the ability to slice ``SegmentationImage`` objects. [#1413] - Added ``mode`` and ``fill_value`` keywords to ``SourceCatalog`` ``make_cutouts`` method. [#1420] - Added ``segment_area`` source property and ``wcs``, ``localbkg_width``, ``apermask_method``, and ``kron_params`` attributes to ``SourceCatalog``. [#1425] - Added the ability to use ``Quantity`` arrays with ``detect_threshold``, ``detect_sources``, ``deblend_sources``, and ``SourceFinder``. [#1436] - The progress bar used when deblending sources now is prepended with "Deblending". [#1439] - Added "windowed" centroids to ``SourceCatalog``. [#1447, #1468] - Added quadratic centroids to ``SourceCatalog``. [#1467, #1469] - Added a ``progress_bar`` option to ``SourceCatalog`` for displaying progress bars when calculating some source properties. [#1471] - ``photutils.utils`` - Added ``xyorigin`` attribute to ``CutoutImage``. [#1419] - Added ``ImageDepth`` class. [#1434] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed a bug in the ``PixelAperture`` ``area_overlap`` method so that the returned value does not inherit the data units. [#1408] - Fixed an issue in ``ApertureStats`` ``get_ids`` for the case when the ID numbers are not sorted (due to slicing). [#1423] - ``photutils.datasets`` - Fixed a bug in the various ``load`` functions where FITS files were not closed. [#1455] - ``photutils.segmentation`` - Fixed an issue in the ``SourceCatalog`` ``kron_photometry``, ``make_kron_apertures``, and ``plot_kron_apertures`` methods where the input minimum Kron and circular radii would not be applied. Instead the instance-level minima would always be used. [#1421] - Fixed an issue where the ``SourceCatalog`` ``plot_kron_apertures`` method would raise an error for a scalar ``SourceCatalog``. [#1421] - Fixed an issue in ``SourceCatalog`` ``get_labels`` for the case when the labels are not sorted (due to slicing). [#1423] API Changes ^^^^^^^^^^^ - Deprecated ``axes`` keyword in favor of ``ax`` for consistency with other packages. [#1432] - Importing tools from all subpackages now requires including the subpackage name. - ``photutils.aperture`` - Inputting ``PixelAperture`` positions as an Astropy ``Quantity`` in pixel units is no longer allowed. [#1398] - Inputting ``SkyAperture`` shape parameters as an Astropy ``Quantity`` in pixel units is no longer allowed. [#1398] - Removed the deprecated ``BoundingBox`` ``as_patch`` method. [#1462] - ``photutils.centroids`` - Removed the deprecated ``oversampling`` keyword in ``centroid_com``. [#1398] - ``photutils.datasets`` - Deprecated the ``load_fermi_image`` function. [#1455] - ``photutils.psf`` - Removed the deprecated ``flux_residual_sigclip`` keyword in ``EPSFBuilder``. Use ``sigma_clip`` instead. [#1398] - PSF photometry classes will no longer emit a RuntimeWarning if the fitted parameter variance is negative. [#1458] - ``photutils.segmentation`` - Removed the deprecated ``sigclip_sigma`` and ``sigclip_iters`` keywords in ``detect_threshold``. Use the ``sigma_clip`` keyword instead. [#1398] - Removed the ``mask_value``, ``sigclip_sigma``, and ``sigclip_iters`` keywords in ``detect_threshold``. Use the ``mask`` or ``sigma_clip`` keywords instead. [#1398] - Removed the deprecated the ``filter_fwhm`` and ``filter_size`` keywords in ``make_source_mask``. Use the ``kernel`` keyword instead. [#1398] - If ``detection_cat`` is input to ``SourceCatalog``, then the detection catalog source centroids and morphological/shape properties will be returned instead of calculating them from the input data. Also, if ``detection_cat`` is input, then the input ``wcs``, ``apermask_method``, and ``kron_params`` keywords will be ignored. [#1425] 1.5.0 (2022-07-12) ------------------ General ^^^^^^^ - Added ``tqdm`` as an optional dependency. [#1364] New Features ^^^^^^^^^^^^ - ``photutils.psf`` - Added a ``mask`` keyword when calling the PSF-fitting classes. [#1350, #1351] - The ``EPSFBuilder`` progress bar will use ``tqdm`` if the optional package is installed. [#1367] - ``photutils.segmentation`` - Added ``SourceFinder`` class, which is a convenience class combining ``detect_sources`` and ``deblend_sources``. [#1344] - Added a ``sigma_clip`` keyword to ``detect_threshold``. [#1354] - Added a ``make_source_mask`` method to ``SegmentationImage``. [#1355] - Added a ``make_2dgaussian_kernel`` convenience function. [#1356] - Allow ``SegmentationImage.make_cmap`` ``background_color`` to be in any matplotlib color format. [#1361] - Added an ``imshow`` convenience method to ``SegmentationImage``. [#1362] - Improved performance of ``deblend_sources``. [#1364] - Added a ``progress_bar`` keyword to ``deblend_sources``. [#1364] - Added a ``'sinh'`` mode to ``deblend_sources``. [#1368] - Improved the resetting of cached ``SegmentationImage`` properties so that custom (non-cached) attributes can be kept. [#1368] - Added a ``nproc`` keyword to enable multiprocessing in ``deblend_sources`` and ``SourceFinder``. [#1372] - Added a ``make_cutouts`` method to ``SourceCatalog`` for making custom-shaped cutout images. [#1376] - Added the ability to set a minimum unscaled Kron radius in ``SourceCatalog``. [#1381] - ``photutils.utils`` - Added a ``circular_footprint`` convenience function. [#1355] - Added a ``CutoutImage`` class. [#1376] Bug Fixes ^^^^^^^^^ - ``photutils.psf`` - Fixed a warning message in ``EPSFFitter``. [#1382] - ``photutils.segmentation`` - Fixed an issue in generating watershed markers used for source deblending. [#1383] API Changes ^^^^^^^^^^^ - ``photutils.centroids`` - Changed the axes order of ``oversampling`` keyword in ``centroid_com`` when input as a tuple. [#1358] - Deprecated the ``oversampling`` keyword in ``centroid_com``. [#1377] - ``photutils.psf`` - Invalid data values (i.e., NaN or inf) are now automatically masked when performing PSF fitting. [#1350] - Deprecated the ``sandbox`` classes ``DiscretePRF`` and ``Reproject``. [#1357] - Changed the axes order of ``oversampling`` keywords when input as a tuple. [#1358] - Removed the unused ``shift_val`` keyword in ``EPSFBuilder`` and ``EPSFModel``. [#1377] - Renamed the ``flux_residual_sigclip`` keyword (now deprecated) to ``sigma_clip`` in ``EPSFBuilder``. [#1378] - The ``EPSFBuilder`` progress bar now requires that the optional ``tqdm`` package be installed. [#1379] - The tools in the PSF package now require keyword-only arguments. [#1386] - ``photutils.segmentation`` - Removed the deprecated ``circular_aperture`` method from ``SourceCatalog``. [#1329] - The ``SourceCatalog`` ``plot_kron_apertures`` method now sets a default ``kron_apers`` value. [#1346] - ``deblend_sources`` no longer allows an array to be input as a segmentation image. It must be a ``SegmentationImage`` object. [#1347] - ``SegmentationImage`` no longer allows array-like input. It must be a numpy ``ndarray``. [#1347] - Deprecated the ``sigclip_sigma`` and ``sigclip_iters`` keywords in ``detect_threshold``. Use the ``sigma_clip`` keyword instead. [#1354] - Deprecated the ``make_source_mask`` function in favor of the ``SegmentationImage.make_source_mask`` method. [#1355] - Deprecated the ``kernel`` keyword in ``detect_sources`` and ``deblend_sources``. Instead, if filtering is desired, input a convolved image directly into the ``data`` parameter. [#1365] - Sources with a data minimum of zero are now treated the same as negative minima (i.e., the mode is changed to "linear") for the "exponential" deblending mode. [#1368] - A single warning (as opposed to 1 per source) is now raised about negative/zero minimum data values using the 'exponential' deblending mode. The affected labels is available in a new "info" attribute. [#1368] - If the mode in ``deblend_sources`` is "exponential" or "sinh" and there are too many potential deblended sources within a given source (watershed markers), a warning will be raised and the mode will be changed to "linear". [#1369] - The ``SourceCatalog`` ``make_circular_apertures`` and ``make_kron_apertures`` methods now return a single aperture (instead of a list with one item) for a scalar ``SourceCatalog``. [#1376] - The ``SourceCatalog`` ``kron_params`` keyword now has an optional third item representing the minimum circular radius. [#1381] - The ``SourceCatalog`` ``kron_radius`` is now set to the minimum Kron radius (the second element of ``kron_params``) if the data or radially weighted data sum to zero. [#1381] - ``photutils.utils`` - The colormap returned from ``make_random_cmap`` now has colors in RGBA format. [#1361] 1.4.0 (2022-03-25) ------------------ General ^^^^^^^ - The minimum required Python is now 3.8. [#1279] - The minimum required Numpy is now 1.18. [#1279] - The minimum required Astropy is now 5.0. [#1279] - The minimum required Matplotlib is now 3.1. [#1279] - The minimum required scikit-image is now 0.15.0 [#1279] - The minimum required gwcs is now 0.16.0 [#1279] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - Added a ``copy`` method to ``Aperture`` objects. [#1304] - Added the ability to compare ``Aperture`` objects for equality. [#1304] - The ``theta`` keyword for ``EllipticalAperture``, ``EllipticalAnnulus``, ``RectangularAperture``, and ``RectangularEllipse`` can now be an Astropy ``Angle`` or ``Quantity`` in angular units. [#1308] - Added an ``ApertureStats`` class for computing statistics of unmasked pixels within an aperture. [#1309, #1314, #1315, #1318] - Added a ``dtype`` keyword to the ``ApertureMask`` ``to_image`` method. [#1320] - ``photutils.background`` - Added an ``alpha`` keyword to the ``Background2D.plot_meshes`` method. [#1286] - Added a ``clip`` keyword to the ``BkgZoomInterpolator`` class. [#1324] - ``photutils.segmentation`` - Added ``SegmentationImage`` ``cmap`` attribute containing a default colormap. [#1319] - Improved the performance of ``SegmentationImage`` and ``SourceCatalog``, especially for large data arrays. [#1320] - Added a ``convolved_data`` keyword to ``SourceCatalog``. This is recommended instead of using the ``kernel`` keyword. [#1321] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed a bug in ``aperture_photometry`` where an error was not raised if the data and error arrays have different units. [#1285]. - ``photutils.background`` - Fixed a bug in ``Background2D`` where using the ``pad`` edge method would result in incorrect image padding if only one of the axes needed padding. [#1292] - ``photutils.centroids`` - Fixed a bug in ``centroid_sources`` where setting ``error``, ``xpeak``, or ``ypeak`` to ``None`` would result in an error. [#1297] - Fixed a bug in ``centroid_quadratic`` where inputting a mask would alter the input data array. [#1317] - ``photutils.segmentation`` - Fixed a bug in ``SourceCatalog`` where a ``UFuncTypeError`` would be raised if the input ``data`` had an integer ``dtype`` [#1312]. API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - A ``ValueError`` is now raised if non-positive sizes are input to sky-based apertures. [#1295] - The ``BoundingBox.plot()`` method now returns a ``matplotlib.patches.Patch`` object. [#1305] - Inputting ``PixelAperture`` positions as an Astropy ``Quantity`` in pixel units is deprecated. [#1310] - Inputting ``SkyAperture`` shape parameters as an Astropy ``Quantity`` in pixel units is deprecated. [#1310] - ``photutils.background`` - Removed the deprecated ``background_mesh_ma`` and ``background_rms_mesh_ma`` ``Background2D`` properties. [#1280] - By default, ``BkgZoomInterpolator`` uses ``clip=True`` to prevent the interpolation from producing values outside the given input range. If backwards-compatibility is needed with older Photutils versions, set ``clip=False``. [#1324] - ``photutils.centroids`` - Removed the deprecated ``centroid_epsf`` and ``gaussian1d_moments`` functions. [#1280] - Importing tools from the centroids subpackage now requires including the subpackage name. [#1280] - ``photutils.morphology`` - Importing tools from the morphology subpackage now requires including the subpackage name. [#1280] - ``photutils.segmentation`` - Removed the deprecated ``source_properties`` function and the ``SourceProperties`` and ``LegacySourceCatalog`` classes. [#1280] - Removed the deprecated the ``filter_kernel`` keyword in the ``detect_sources``, ``deblend_sources``, and ``make_source_mask`` functions. [#1280] - A ``TypeError`` is raised if the input array to ``SegmentationImage`` does not have integer type. [#1319] - A ``SegmentationImage`` may contain an array of all zeros. [#1319] - Deprecated the ``mask_value`` keyword in ``detect_threshold``. Use the ``mask`` keyword instead. [#1322] - Deprecated the ``filter_fwhm`` and ``filter_size`` keywords in ``make_source_mask``. Use the ``kernel`` keyword instead. [#1322] 1.3.0 (2021-12-21) ------------------ General ^^^^^^^ - The metadata in output tables now contains version information for all dependencies. [#1274] New Features ^^^^^^^^^^^^ - ``photutils.centroids`` - Extra keyword arguments can be input to ``centroid_sources`` that are then passed on to the ``centroid_func`` if supported. [#1276, #1278] - ``photutils.segmentation`` - Added ``copy`` method to ``SourceCatalog``. [#1264] - Added ``kron_photometry`` method to ``SourceCatalog``. [#1264] - Added ``add_extra_property``, ``remove_extra_property``, ``remove_extra_properties``, and ``rename_extra_property`` methods and ``extra_properties`` attribute to ``SourceCatalog``. [#1264, #1268] - Added ``name`` and ``overwrite`` keywords to ``SourceCatalog`` ``circular_photometry`` and ``fluxfrac_radius`` methods. [#1264] - ``SourceCatalog`` ``fluxfrac_radius`` was improved for cases where the source flux doesn't monotonically increase with increasing radius. [#1264] - Added ``meta`` and ``properties`` attributes to ``SourceCatalog``. [#1268] - The ``SourceCatalog`` output table (using ``to_table``) ``meta`` dictionary now includes a field for the date/time. [#1268] - Added ``SourceCatalog`` ``make_kron_apertures`` method. [#1268] - Added ``SourceCatalog`` ``plot_circular_apertures`` and ``plot_kron_apertures`` methods. [#1268] Bug Fixes ^^^^^^^^^ - ``photutils.segmentation`` - If ``detection_catalog`` is input to ``SourceCatalog`` then the detection centroids are used to calculate the ``circular_aperture``, ``circular_photometry``, and ``fluxfrac_radius``. [#1264] - Units are applied to ``SourceCatalog`` ``circular_photometry`` output if the input data has units. [#1264] - ``SourceCatalog`` ``circular_photometry`` returns scalar values if catalog is scalar. [#1264] - ``SourceCatalog`` ``fluxfrac_radius`` returns a ``Quantity`` with pixel units. [#1264] - Fixed a bug where the ``SourceCatalog`` ``detection_catalog`` was not indexed/sliced when ``SourceCatalog`` was indexed/sliced. [#1268] - ``SourceCatalog`` ``circular_photometry`` now returns NaN for completely-masked sources. [#1268] - ``SourceCatalog`` ``kron_flux`` is always NaN for sources where ``kron_radius`` is NaN. [#1268] - ``SourceCatalog`` ``fluxfrac_radius`` now returns NaN if ``kron_flux`` is zero. [#1268] API Changes ^^^^^^^^^^^ - ``photutils.centroids`` - A ``ValueError`` is now raised in ``centroid_sources`` if the input ``xpos`` or ``ypos`` is outside of the input ``data``. [#1276] - A ``ValueError`` is now raised in ``centroid_quadratic`` if the input ``xpeak`` or ``ypeak`` is outside of the input ``data``. [#1276] - NaNs are now returned from ``centroid_sources`` where the centroid failed. This is usually due to a ``box_size`` that is too small when using a fitting-based centroid function. [#1276] - ``photutils.segmentation`` - Renamed the ``SourceCatalog`` ``circular_aperture`` method to ``make_circular_apertures``. The old name is deprecated. [#1268] - The ``SourceCatalog`` ``kron_params`` keyword must have a minimum circular radius that is greater than zero. The default value is now 1.0. [#1268] - ``detect_sources`` now uses ``astropy.convolution.convolve``, which allows for masking pixels. [#1269] 1.2.0 (2021-09-23) ------------------ General ^^^^^^^ - The minimum required scipy version is 1.6.0 [#1239] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - Added a ``mask`` keyword to the ``area_overlap`` method. [#1241] - ``photutils.background`` - Improved the performance of ``Background2D`` by up to 10-50% when the optional ``bottleneck`` package is installed. [#1232] - Added a ``masked`` keyword to the background classes ``MeanBackground``, ``MedianBackground``, ``ModeEstimatorBackground``, ``MMMBackground``, ``SExtractorBackground``, ``BiweightLocationBackground``, ``StdBackgroundRMS``, ``MADStdBackgroundRMS``, and ``BiweightScaleBackgroundRMS``. [#1232] - Enable all background classes to work with ``Quantity`` inputs. [#1233] - Added a ``markersize`` keyword to the ``Background2D`` method ``plot_meshes``. [#1234] - Added ``__repr__`` methods to all background classes. [#1236] - Added a ``grid_mode`` keyword to ``BkgZoomInterpolator``. [#1239] - ``photutils.detection`` - Added a ``xycoords`` keyword to ``DAOStarFinder`` and ``IRAFStarFinder``. [#1248] - ``photutils.psf`` - Enabled the reuse of an output table from ``BasicPSFPhotometry`` and its subclasses as an initial guess for another photometry run. [#1251] - Added the ability to skip the ``group_maker`` step by inputing an initial guess table with a ``group_id`` column. [#1251] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed a bug when converting between pixel and sky apertures with a ``gwcs`` object. [#1221] - ``photutils.background`` - Fixed an issue where ``Background2D`` could fail when using the ``'pad'`` edge method. [#1227] - ``photutils.detection`` - Fixed the ``DAOStarFinder`` import deprecation message. [#1195] - ``photutils.morphology`` - Fixed an issue in ``data_properties`` where a scalar background input would raise an error. [#1198] - ``photutils.psf`` - Fixed an issue in ``prepare_psf_model`` when ``xname`` or ``yname`` was ``None`` where the model offsets were applied in the wrong direction, resulting in the initial photometry guesses not being improved by the fit. [#1199] - ``photutils.segmentation`` - Fixed an issue in ``SourceCatalog`` where the user-input ``mask`` was ignored when ``apermask_method='correct'`` for Kron-related calculations. [#1210] - Fixed an issue in ``SourceCatalog`` where the ``segment`` array could incorrectly have units. [#1220] - ``photutils.utils`` - Fixed an issue in ``ShepardIDWInterpolator`` to allow its initialization with scalar data values and coordinate arrays having more than one dimension. [#1226] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - The ``ApertureMask.get_values()`` function now returns an empty array if there is no overlap with the data. [#1212] - Removed the deprecated ``BoundingBox.slices`` and ``PixelAperture.bounding_boxes`` attributes. [#1215] - ``photutils.background`` - Invalid data values (i.e., NaN or inf) are now automatically masked in ``Background2D``. [#1232] - The background classes ``MeanBackground``, ``MedianBackground``, ``ModeEstimatorBackground``, ``MMMBackground``, ``SExtractorBackground``, ``BiweightLocationBackground``, ``StdBackgroundRMS``, ``MADStdBackgroundRMS``, and ``BiweightScaleBackgroundRMS`` now return by default a ``numpy.ndarray`` with ``np.nan`` values representing masked pixels instead of a masked array. A masked array can be returned by setting ``masked=True``. [#1232] - Deprecated the ``Background2D`` attributes ``background_mesh_ma`` and ``background_rms_mesh_ma``. They have been renamed to ``background_mesh_masked`` and ``background_rms_mesh_masked``. [#1232] - By default, ``BkgZoomInterpolator`` now uses ``grid_mode=True``. For zooming 2D images, this keyword should be set to True, which makes the interpolator's behavior consistent with ``scipy.ndimage.map_coordinates``, ``skimage.transform.resize``, and ``OpenCV (cv2.resize)``. If backwards-compatibility is needed with older Photutils versions, set ``grid_mode=False``. [#1239] - ``photutils.centroids`` - Deprecated the ``gaussian1d_moments`` and ``centroid_epsf`` functions. [#1240] - ``photutils.datasets`` - Removed the deprecated ``random_state`` keyword in the ``apply_poisson_noise``, ``make_noise_image``, ``make_random_models_table``, and ``make_random_gaussians_table`` functions. [#1244] - ``make_random_models_table`` and ``make_random_gaussians_table`` now return an astropy ``QTable`` with version metadata. [#1247] - ``photutils.detection`` - ``DAOStarFinder``, ``IRAFStarFinder``, and ``find_peaks`` now return an astropy ``QTable`` with version metadata. [#1247] - The ``StarFinder`` ``label`` column was renamed to ``id`` for consistency with the other star finder classes. [#1254] - ``photutils.isophote`` - The ``Isophote`` ``to_table`` method nows return an astropy ``QTable`` with version metadata. [#1247] - ``photutils.psf`` - ``BasicPSFPhotometry``, ``IterativelySubtractedPSFPhotometry``, and ``DAOPhotPSFPhotometry`` now return an astropy ``QTable`` with version metadata. [#1247] - ``photutils.segmentation`` - Deprecated the ``filter_kernel`` keyword in the ``detect_sources``, ``deblend_sources``, and ``make_source_mask`` functions. It has been renamed to simply ``kernel`` for consistency with ``SourceCatalog``. [#1242] - Removed the deprecated ``random_state`` keyword in the ``make_cmap`` method. [#1244] - The ``SourceCatalog`` ``to_table`` method nows return an astropy ``QTable`` with version metadata. [#1247] - ``photutils.utils`` - Removed the deprecated ``check_random_state`` function. [#1244] - Removed the deprecated ``random_state`` keyword in the ``make_random_cmap`` function. [#1244] 1.1.0 (2021-03-20) ------------------ General ^^^^^^^ - The minimum required python version is 3.7. [#1120] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - The ``PixelAperture.plot()`` method now returns a list of ``matplotlib.patches.Patch`` objects. [#923] - Added an ``area_overlap`` method for ``PixelAperture`` objects that gives the overlapping area of the aperture on the data. [#874] - Added a ``get_overlap_slices`` method and a ``center`` attribute to ``BoundingBox``. [#1157] - Added a ``get_values`` method to ``ApertureMask`` that returns a 1D array of mask-weighted values. [#1158, #1161] - Added ``get_overlap_slices`` method to ``ApertureMask``. [#1165] - ``photutils.background`` - The ``Background2D`` class now accepts astropy ``NDData``, ``CCDData``, and ``Quantity`` objects as data inputs. [#1140] - ``photutils.detection`` - Added a ``StarFinder`` class to detect stars with a user-defined kernel. [#1182] - ``photutils.isophote`` - Added the ability to specify the output columns in the ``IsophoteList`` ``to_table`` method. [#1117] - ``photutils.psf`` - The ``EPSFStars`` class is now usable with multiprocessing. [#1152] - Slicing ``EPSFStars`` now returns an ``EPSFStars`` instance. [#1185] - ``photutils.segmentation`` - Added a modified, significantly faster, ``SourceCatalog`` class. [#1170, #1188, #1191] - Added ``circular_aperture`` and ``circular_photometry`` methods to the ``SourceCatalog`` class. [#1188] - Added ``fwhm`` property to the ``SourceCatalog`` class. [#1191] - Added ``fluxfrac_radius`` method to the ``SourceCatalog`` class. [#1192] - Added a ``bbox`` attribute to ``SegmentationImage``. [#1187] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Slicing a scalar ``Aperture`` object now raises an informative error message. [#1154] - Fixed an issue where ``ApertureMask.multiply`` ``fill_value`` was not applied to pixels outside of the aperture mask, but within the aperture bounding box. [#1158] - Fixed an issue where ``ApertureMask.cutout`` would raise an error if ``fill_value`` was non-finite and the input array was integer type. [#1158] - Fixed an issue where ``RectangularAnnulus`` with a non-default ``h_in`` would give an incorrect ``ApertureMask``. [#1160] - ``photutils.isophote`` - Fix computation of gradient relative error when gradient=0. [#1180] - ``photutils.psf`` - Fixed a bug in ``EPSFBuild`` where a warning was raised if the input ``smoothing_kernel`` was an ``numpy.ndarray``. [#1146] - Fixed a bug that caused photometry to fail on an ``EPSFmodel`` with multiple stars in a group. [#1135] - Added a fallback ``aperture_radius`` for PSF models without a FWHM or sigma attribute, raising a warning. [#740] - ``photutils.segmentation`` - Fixed ``SourceProperties`` ``local_background`` to work with Quantity data inputs. [#1162] - Fixed ``SourceProperties`` ``local_background`` for sources near the image edges. [#1162] - Fixed ``SourceProperties`` ``kron_radius`` for sources that are completely masked. [#1164] - Fixed ``SourceProperties`` Kron properties for sources near the image edges. [#1167] - Fixed ``SourceProperties`` Kron mask correction. [#1167] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - Deprecated the ``BoundingBox`` ``slices`` attribute. Use the ``get_overlap_slices`` method instead. [#1157] - ``photutils.centroids`` - Removed the deprecated ``fit_2dgaussian`` function and ``GaussianConst2D`` class. [#1147] - Importing tools from the centroids subpackage without including the subpackage name is deprecated. [#1190] - ``photutils.detection`` - Importing the ``DAOStarFinder``, ``IRAFStarFinder``, and ``StarFinderBase`` classes from the deprecated ``findstars.py`` module is now deprecated. These classes can be imported using ``from photutils.detection import ``. [#1173] - Importing the ``find_peaks`` function from the deprecated ``core.py`` module is now deprecated. This function can be imported using ``from photutils.detection import find_peaks``. [#1173] - ``photutils.morphology`` - Importing tools from the morphology subpackage without including the subpackage name is deprecated. [#1190] - ``photutils.segmentation`` - Deprecated the ``"mask_all"`` option in the ``SourceProperties`` ``kron_params`` keyword. [#1167] - Deprecated ``source_properties``, ``SourceProperties``, and ``LegacySourceCatalog``. Use the new ``SourceCatalog`` function instead. [#1170] - The ``detect_threshold`` function was moved to the ``segmentation`` subpackage. [#1171] - Removed the ability to slice ``SegmentationImage``. Instead slice the ``segments`` attribute. [#1187] 1.0.2 (2021-01-20) ------------------ General ^^^^^^^ - ``photutils.background`` - Improved the performance of ``Background2D`` (e.g., by a factor of ~4 with 2048x2048 input arrays when using the default interpolator). [#1103, #1108] Bug Fixes ^^^^^^^^^ - ``photutils.background`` - Fixed a bug with ``Background2D`` where using ``BkgIDWInterpolator`` would give incorrect results. [#1104] - ``photutils.isophote`` - Corrected calculations of upper harmonics and their errors [#1089] - Fixed bug that caused an infinite loop when the sample extracted from an image has zero length. [#1129] - Fixed a bug where the default ``fixed_parameters`` in ``EllipseSample.update()`` were not defined. [#1139] - ``photutils.psf`` - Fixed a bug where very incorrect PSF-fitting uncertainties could be returned when the astropy fitter did not return fit uncertainties. [#1143] - Changed the default ``recentering_func`` in ``EPSFBuilder``, to avoid convergence issues. [#1144] - ``photutils.segmentation`` - Fixed an issue where negative Kron radius values could be returned, which would cause an error when calculating Kron fluxes. [#1132] - Fixed an issue where an error was raised with ``SegmentationImage.remove_border_labels()`` with ``relabel=True`` when no segments remain. [#1133] 1.0.1 (2020-09-24) ------------------ Bug Fixes ^^^^^^^^^ - ``photutils.psf`` - Fixed checks on ``oversampling`` factors. [#1086] 1.0.0 (2020-09-22) ------------------ General ^^^^^^^ - The minimum required python version is 3.6. [#952] - The minimum required astropy version is 4.0. [#1081] - The minimum required numpy version is 1.17. [#1079] - Removed ``astropy-helpers`` and updated the package infrastructure as described in Astropy APE 17. [#915] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - Added ``b_in`` as an optional ellipse annulus keyword. [#1070] - Added ``h_in`` as an optional rectangle annulus keyword. [#1070] - ``photutils.background`` - Added ``coverage_mask`` and ``fill_value`` keyword options to ``Background2D``. [#1061] - ``photutils.centroids`` - Added quadratic centroid estimator function (``centroid_quadratic``). [#1067] - ``photutils.psf`` - Added the ability to use odd oversampling factors in ``EPSFBuilder``. [#1076] - ``photutils.segmentation`` - Added Kron radius, flux, flux error, and aperture to ``SourceProperties``. [#1068] - Added local background to ``SourceProperties``. [#1075] Bug Fixes ^^^^^^^^^ - ``photutils.isophote`` - Fixed a typo in the calculation of the ``b4`` higher-order harmonic coefficient in ``build_ellipse_model``. [#1052] - Fixed a bug where ``build_ellipse_model`` falls into an infinite loop when the pixel to fit is outside of the image. [#1039] - Fixed a bug where ``build_ellipse_model`` falls into an infinite loop under certain image/parameters input combinations. [#1056] - ``photutils.psf`` - Fixed a bug in ``subtract_psf`` caused by using a fill_value of np.nan with an integer input array. [#1062] - ``photutils.segmentation`` - Fixed a bug where ``source_properties`` would fail with unitless ``gwcs.wcs.WCS`` objects. [#1020] - ``photutils.utils`` - The ``effective_gain`` parameter in ``calc_total_error`` can now be zero (or contain zero values). [#1019] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - Aperture pixel positions can no longer be shaped as 2xN. [#953] - Removed the deprecated ``units`` keyword in ``aperture_photometry`` and ``PixelAperture.do_photometry``. [#953] - ``PrimaryHDU``, ``ImageHDU``, and ``HDUList`` can no longer be input to ``aperture_photometry``. [#953] - Removed the deprecated the Aperture ``mask_area`` method. [#953] - Removed the deprecated Aperture plot keywords ``ax`` and ``indices``. [#953] - ``photutils.background`` - Removed the deprecated ``ax`` keyword in ``Background2D.plot_meshes``. [#953] - ``Background2D`` keyword options can not be input as positional arguments. [#1061] - ``photutils.centroids`` - ``centroid_1dg``, ``centroid_2dg``, ``gaussian1d_moments``, ``fit_2dgaussian``, and ``GaussianConst2D`` have been moved to a new ``photutils.centroids.gaussian`` module. [#1064] - Deprecated ``fit_2dgaussian`` and ``GaussianConst2D``. [#1064] - ``photutils.datasets`` - Removed the deprecated ``type`` keyword in ``make_noise_image``. [#953] - Renamed the ``random_state`` keyword (deprecated) to ``seed`` in ``apply_poisson_noise``, ``make_noise_image``, ``make_random_models_table``, and ``make_random_gaussians_table`` functions. [#1080] - ``photutils.detection`` - Removed the deprecated ``snr`` keyword in ``detect_threshold``. [#953] - ``photutils.psf`` - Added ``flux_residual_sigclip`` as an input parameter, allowing for custom sigma clipping options in ``EPSFBuilder``. [#984] - Added ``extra_output_cols`` as a parameter to ``BasicPSFPhotometry``, ``IterativelySubtractedPSFPhotometry`` and ``DAOPhotPSFPhotometry``. [#745] - ``photutils.segmentation`` - Removed the deprecated ``SegmentationImage`` methods ``cmap`` and ``relabel``. [#953] - Removed the deprecated ``SourceProperties`` ``values`` and ``coords`` attributes. [#953] - Removed the deprecated ``xmin/ymin`` and ``xmax/ymax`` properties. [#953] - Removed the deprecated ``snr`` and ``mask_value`` keywords in ``make_source_mask``. [#953] - Renamed the ``random_state`` keyword (deprecated) to ``seed`` in the ``make_cmap`` method. [#1080] - ``photutils.utils`` - Removed the deprecated ``random_cmap``, ``mask_to_mirrored_num``, ``get_version_info``, ``filter_data``, and ``std_blocksum`` functions. [#953] - Removed the deprecated ``wcs_helpers`` functions ``pixel_scale_angle_at_skycoord``, ``assert_angle_or_pixel``, ``assert_angle``, and ``pixel_to_icrs_coords``. [#953] - Deprecated the ``check_random_state`` function. [#1080] - Renamed the ``random_state`` keyword (deprecated) to ``seed`` in the ``make_random_cmap`` function. [#1080] 0.7.2 (2019-12-09) ------------------ Bug Fixes ^^^^^^^^^ - ``photutils.isophote`` - Fixed computation of upper harmonics ``a3``, ``b3``, ``a4``, and ``b4`` in the ellipse fitting algorithm. [#1008] - ``photutils.psf`` - Fix to algorithm in ``EPSFBuilder``, causing issues where ePSFs failed to build. [#974] - Fix to ``IterativelySubtractedPSFPhotometry`` where an error could be thrown when a ``Finder`` was passed which did not return ``None`` if no sources were found. [#986] - Fix to ``centroid_epsf`` where the wrong oversampling factor was used along the y axis. [#1002] 0.7.1 (2019-10-09) ------------------ Bug Fixes ^^^^^^^^^ - ``photutils.psf`` - Fix to ``IterativelySubtractedPSFPhotometry`` where the residual image was not initialized when ``bkg_estimator`` was not supplied. [#942] - ``photutils.segmentation`` - Fixed a labeling bug in ``deblend_sources``. [#961] - Fixed an issue in ``source_properties`` when the input ``data`` is a ``Quantity`` array. [#963] 0.7 (2019-08-14) ---------------- General ^^^^^^^ - Any WCS object that supports the `astropy shared interface for WCS `_ is now supported. [#899] - Added a new ``photutils.__citation__`` and ``photutils.__bibtex__`` attributes which give a citation for photutils in bibtex format. [#926] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - Added parameter validation for all aperture classes. [#846] - Added ``from_float``, ``as_artist``, ``union`` and ``intersection`` methods to ``BoundingBox`` class. [#851] - Added ``shape`` and ``isscalar`` properties to Aperture objects. [#852] - Significantly improved the performance (~10-20 times faster) of aperture photometry, especially when using ``errors`` and ``Quantity`` inputs with many aperture positions. [#861] - ``aperture_photometry`` now supports ``NDData`` with ``StdDevUncertainty`` to input errors. [#866] - The ``mode`` keyword in the ``to_sky`` and ``to_pixel`` aperture methods was removed to implement the shared WCS interface. All WCS transforms now include distortions (if present). [#899] - ``photutils.datasets`` - Added ``make_gwcs`` function to create an example ``gwcs.wcs.WCS`` object. [#871] - ``photutils.isophote`` - Significantly improved the performance (~5 times faster) of ellipse fitting. [#826] - Added the ability to individually fix the ellipse-fitting parameters. [#922] - ``photutils.psf`` - Added new centroiding function ``centroid_epsf``. [#816] - ``photutils.segmentation`` - Significantly improved the performance of relabeling in segmentation images (e.g., ``remove_labels``, ``keep_labels``). [#810] - Added new ``background_area`` attribute to ``SegmentationImage``. [#825] - Added new ``data_ma`` attribute to ``Segment``. [#825] - Added new ``SegmentationImage`` methods: ``find_index``, ``find_indices``, ``find_areas``, ``check_label``, ``keep_label``, ``remove_label``, and ``reassign_labels``. [#825] - Added ``__repr__`` and ``__str__`` methods to ``SegmentationImage``. [#825] - Added ``slices``, ``indices``, and ``filtered_data_cutout_ma`` attributes to ``SourceProperties``. [#858] - Added ``__repr__`` and ``__str__`` methods to ``SourceProperties`` and ``SourceCatalog``. [#858] - Significantly improved the performance of calculating the ``background_at_centroid`` property in ``SourceCatalog``. [#863] - The default output table columns (source properties) are defined in a publicly-accessible variable called ``photutils.segmentation.properties.DEFAULT_COLUMNS``. [#863] - Added the ``gini`` source property representing the Gini coefficient. [#864] - Cached (lazy) properties can now be reset in ``SegmentationImage`` subclasses. [#916] - Significantly improved the performance of ``deblend_sources``. It is ~40-50% faster for large images (e.g., 4k x 4k) with a few thousand of sources. [#924] - ``photutils.utils`` - Added ``NoDetectionsWarning`` class. [#836] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed an issue where the ``ApertureMask.cutout`` method would drop the data units when ``copy=True``. [#842] - Fixed a corner-case issue where aperture photometry would return NaN for non-finite data values outside the aperture but within the aperture bounding box. [#843] - Fixed an issue where the ``celestial_center`` column (for sky apertures) would be a length-1 array containing a ``SkyCoord`` object instead of a length-1 ``SkyCoord`` object. [#844] - ``photutils.isophote`` - Fixed an issue where the linear fitting mode was not working. [#912] - Fixed the radial gradient computation [#934]. - ``photutils.psf`` - Fixed a bug in the ``EPSFStar`` ``register_epsf`` and ``compute_residual_image`` computations. [#885] - A ValueError is raised if ``aperture_radius`` is not input and cannot be determined from the input ``psf_model``. [#903] - Fixed normalization of ePSF model, now correctly normalizing on undersampled pixel grid. [#817] - ``photutils.segmentation`` - Fixed an issue where ``deblend_sources`` could fail for sources with labels that are a power of 2 and greater than 255. [#806] - ``SourceProperties`` and ``source_properties`` will no longer raise an exception if a source is completely masked. [#822] - Fixed an issue in ``SourceProperties`` and ``source_properties`` where inf values in the data array were not automatically masked. [#822] - ``error`` and ``background`` arrays are now always masked identically to the input ``data``. [#822] - Fixed the ``perimeter`` property to take into account the source mask. [#822] - Fixed the ``background_at_centroid`` source property to use bilinear interpolation. [#822] - Fixed ``SegmentationImage`` ``outline_segments`` to include outlines along the image boundaries. [#825] - Fixed ``SegmentationImage.is_consecutive`` to return ``True`` only if the labels are consecutive and start with label=1. [#886] - Fixed a bug in ``deblend_sources`` where sources could be deblended too much when ``connectivity=8``. [#890] - Fixed a bug in ``deblend_sources`` where the ``contrast`` parameter had little effect if the original segment contained three or more sources. [#890] - ``photutils.utils`` - Fixed a bug in ``filter_data`` where units were dropped for data ``Quantity`` objects. [#872] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - Deprecated inputting aperture pixel positions shaped as 2xN. [#847] - Renamed the ``celestial_center`` column to ``sky_center`` in the ``aperture_photometry`` output table. [#848] - Aperture objects defined with a single (x, y) position (input as 1D) are now considered scalar objects, which can be checked with the new ``isscalar`` Aperture property. [#852] - Non-scalar Aperture objects can now be indexed, sliced, and iterated. [#852] - Scalar Aperture objects now return scalar ``positions`` and ``bounding_boxes`` properties and its ``to_mask`` method returns an ``ApertureMask`` object instead of a length-1 list containing an ``ApertureMask``. [#852] - Deprecated the Aperture ``mask_area`` method. [#853] - Aperture ``area`` is now an attribute instead of a method. [#854] - The Aperture plot keyword ``ax`` was deprecated and renamed to ``axes``. [#854] - Deprecated the ``units`` keyword in ``aperture_photometry`` and the ``PixelAperture.do_photometry`` method. [#866, #861] - Deprecated ``PrimaryHDU``, ``ImageHDU``, and ``HDUList`` inputs to ``aperture_photometry``. [#867] - The ``aperture_photometry`` function moved to a new ``photutils.aperture.photometry`` module. [#876] - Renamed the ``bounding_boxes`` attribute for pixel-based apertures to ``bbox`` for consistency. [#896] - Deprecated the ``BoundingBox`` ``as_patch`` method (instead use ``as_artist``). [#851] - ``photutils.background`` - The ``Background2D`` ``plot_meshes`` keyword ``ax`` was deprecated and renamed to ``axes``. [#854] - ``photutils.datasets`` - The ``make_noise_image`` ``type`` keyword was deprecated and renamed to ``distribution``. [#877] - ``photutils.detection`` - Removed deprecated ``subpixel`` keyword for ``find_peaks``. [#835] - ``DAOStarFinder``, ``IRAFStarFinder``, and ``find_peaks`` now return ``None`` if no source/peaks are found. Also, for this case a ``NoDetectionsWarning`` is issued. [#836] - Renamed the ``snr`` (deprecated) keyword to ``nsigma`` in ``detect_threshold``. [#917] - ``photutils.isophote`` - Isophote central values and intensity gradients are now returned to the output table. [#892] - The ``EllipseSample`` ``update`` method now needs to know the fix/fit state of each individual parameter. This can be passed to it via a ``Geometry`` instance, e.g., ``update(geometry.fix)``. [#922] - ``photutils.psf`` - ``FittableImageModel`` and subclasses now allow for different ``oversampling`` factors to be specified in the x and y directions. [#834] - Removed ``pixel_scale`` keyword from ``EPSFStar``, ``EPSFBuilder``, and ``EPSFModel``. [#815] - Added ``oversampling`` keyword to ``centroid_com``. [#816] - Removed deprecated ``Star``, ``Stars``, and ``LinkedStar`` classes. [#894] - Removed ``recentering_boxsize`` and ``center_accuracy`` keywords and added ``norm_radius`` and ``shift_value`` keywords in ``EPSFBuilder``. [#817] - Added ``norm_radius`` and ``shift_value`` keywords to ``EPSFModel``. [#817] - ``photutils.segmentation`` - Removed deprecated ``SegmentationImage`` attributes ``data_masked``, ``max``, and ``is_sequential`` and methods ``area`` and ``relabel_sequential``. [#825] - Renamed ``SegmentationImage`` methods ``cmap`` (deprecated) to ``make_cmap`` and ``relabel`` (deprecated) to ``reassign_label``. The new ``reassign_label`` method gains a ``relabel`` keyword. [#825] - The ``SegmentationImage`` ``segments`` and ``slices`` attributes now have the same length as ``labels`` (no ``None`` placeholders). [#825] - ``detect_sources`` now returns ``None`` if no sources are found. Also, for this case a ``NoDetectionsWarning`` is issued. [#836] - The ``SegmentationImage`` input ``data`` array must contain at least one non-zero pixel and must not contain any non-finite values. [#836] - A ``ValueError`` is raised if an empty list is input into ``SourceCatalog`` or no valid sources are defined in ``source_properties``. [#836] - Deprecated the ``values`` and ``coords`` attributes in ``SourceProperties``. [#858] - Deprecated the unused ``mask_value`` keyword in ``make_source_mask``. [#858] - The ``bbox`` property now returns a ``BoundingBox`` instance. [#863] - The ``xmin/ymin`` and ``xmax/ymax`` properties have been deprecated with the replacements having a ``bbox_`` prefix (e.g., ``bbox_xmin``). [#863] - The ``orientation`` property is now returned as a ``Quantity`` instance in units of degrees. [#863] - Renamed the ``snr`` (deprecated) keyword to ``nsigma`` in ``make_source_mask``. [#917] - ``photutils.utils`` - Renamed ``random_cmap`` to ``make_random_cmap``. [#825] - Removed deprecated ``cutout_footprint`` function. [#835] - Deprecated the ``wcs_helpers`` functions ``pixel_scale_angle_at_skycoord``, ``assert_angle_or_pixel``, ``assert_angle``, and ``pixel_to_icrs_coords``. [#846] - Removed deprecated ``interpolate_masked_data`` function. [#895] - Deprecated the ``mask_to_mirrored_num`` function. [#895] - Deprecated the ``get_version_info``, ``filter_data``, and ``std_blocksum`` functions. [#918] 0.6 (2018-12-11) ---------------- General ^^^^^^^ - Versions of Numpy <1.11 are no longer supported. [#783] New Features ^^^^^^^^^^^^ - ``photutils.detection`` - ``DAOStarFinder`` and ``IRAFStarFinder`` gain two new parameters: ``brightest`` to keep the top ``brightest`` (based on the flux) objects in the returned catalog (after all other filtering has been applied) and ``peakmax`` to exclude sources with peak pixel values larger or equal to ``peakmax``. [#750] - Added a ``mask`` keyword to ``DAOStarFinder`` and ``IRAFStarFinder`` that can be used to mask regions of the input image. [#759] - ``photutils.psf`` - The ``Star``, ``Stars``, and ``LinkedStars`` classes are now deprecated and have been renamed ``EPSFStar``, ``EPSFStars``, and ``LinkedEPSFStars``, respectively. [#727] - Added a ``GriddedPSFModel`` class for spatially-dependent PSFs. [#772] - The ``pixel_scale`` keyword in ``EPSFStar``, ``EPSFBuilder`` and ``EPSFModel`` is now deprecated. Use the ``oversampling`` keyword instead. [#780] API Changes ^^^^^^^^^^^ - ``photutils.detection`` - The ``find_peaks`` function now returns an empty ``astropy.table.Table`` instead of an empty list if the input data is an array of constant values. [#709] - The ``find_peaks`` function will no longer issue a RuntimeWarning if the input data contains NaNs. [#712] - If no sources/peaks are found, ``DAOStarFinder``, ``IRAFStarFinder``, and ``find_peaks`` now will return an empty table with column names and types. [#758, #762] - ``photutils.psf`` - The ``photutils.psf.funcs.py`` module was renamed ``photutils.psf.utils.py``. The ``prepare_psf_model`` and ``get_grouped_psf_model`` functions were also moved to this new ``utils.py`` module. [#777] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - If a single aperture is input as a list into the ``aperture_photometry`` function, then the output columns will be called ``aperture_sum_0`` and ``aperture_sum_err_0`` (if errors are used). Previously these column names did not have the trailing "_0". [#779] - ``photutils.segmentation`` - Fixed a bug in the computation of ``sky_bbox_ul``, ``sky_bbox_lr``, ``sky_bbox_ur`` in the ``SourceCatalog``. [#716] Other Changes and Additions ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - Updated background and detection functions that call ``astropy.stats.SigmaClip`` or ``astropy.stats.sigma_clipped_stats`` to support both their ``iters`` (for astropy < 3.1) and ``maxiters`` keywords. [#726] 0.5 (2018-08-06) ---------------- General ^^^^^^^ - Versions of Python <3.5 are no longer supported. [#702, #703] - Versions of Numpy <1.10 are no longer supported. [#697, #703] - Versions of Pytest <3.1 are no longer supported. [#702] - ``pytest-astropy`` is now required to run the test suite. [#702, #703] - The documentation build now uses the Sphinx configuration from ``sphinx-astropy`` rather than from ``astropy-helpers``. [#702] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - Added ``plot`` and ``to_aperture`` methods to ``BoundingBox``. [#662] - Added default theta value for elliptical and rectangular apertures. [#674] - ``photutils.centroids`` - Added a ``centroid_sources`` function to calculate centroid of many sources in a single image. [#656] - An n-dimensional array can now be input into the ``centroid_com`` function. [#685] - ``photutils.datasets`` - Added a ``load_simulated_hst_star_image`` function to load a simulated HST WFC3/IR F160W image of stars. [#695] - ``photutils.detection`` - Added a ``centroid_func`` keyword to ``find_peaks``. The ``subpixels`` keyword is now deprecated. [#656] - The ``find_peaks`` function now returns ``SkyCoord`` objects in the table instead of separate RA and Dec. columns. [#656] - The ``find_peaks`` function now returns an empty Table and issues a warning when no peaks are found. [#668] - ``photutils.psf`` - Added tools to build and fit an effective PSF (``EPSFBuilder`` and ``EPSFFitter``). [#695] - Added ``extract_stars`` function to extract cutouts of stars used to build an ePSF. [#695] - Added ``EPSFModel`` class to hold a fittable ePSF model. [#695] - ``photutils.segmentation`` - Added a ``mask`` keyword to the ``detect_sources`` function. [#621] - Renamed ``SegmentationImage`` ``max`` attribute to ``max_label``. ``max`` is deprecated. [#662] - Added a ``Segment`` class to hold the cutout image and properties of single labeled region (source segment). [#662] - Deprecated the ``SegmentationImage`` ``area`` method. Instead, use the ``areas`` attribute. [#662] - Renamed ``SegmentationImage`` ``data_masked`` attribute to ``data_ma``. ``data_masked`` is deprecated. [#662] - Renamed ``SegmentationImage`` ``is_sequential`` attribute to ``is_consecutive``. ``is_sequential`` is deprecated. [#662] - Renamed ``SegmentationImage`` ``relabel_sequential`` attribute to ``relabel_consecutive``. ``relabel_sequential`` is deprecated. [#662] - Added a ``missing_labels`` property to ``SegmentationImage``. [#662] - Added a ``check_labels`` method to ``SegmentationImage``. The ``check_label`` method is deprecated. [#662] - ``photutils.utils`` - Deprecated the ``cutout_footprint`` function. [#656] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed a bug where quantity inputs to the aperture classes would sometimes fail. [#693] - ``photutils.detection`` - Fixed an issue in ``detect_sources`` where in some cases sources with a size less than ``npixels`` could be returned. [#663] - Fixed an issue in ``DAOStarFinder`` where in some cases a few too many sources could be returned. [#671] - ``photutils.isophote`` - Fixed a bug where isophote fitting would fail when the initial center was not specified for an image with an elongated aspect ratio. [#673] - ``photutils.segmentation`` - Fixed ``deblend_sources`` when other sources are in the neighborhood. [#617] - Fixed ``source_properties`` to handle the case where the data contain one or more NaNs. [#658] - Fixed an issue with ``deblend_sources`` where sources were not deblended where the data contain one or more NaNs. [#658] - Fixed the ``SegmentationImage`` ``areas`` attribute to not include the zero (background) label. [#662] Other Changes and Additions ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - ``photutils.isophote`` - Corrected the units for isophote ``sarea`` in the documentation. [#657] 0.4 (2017-10-30) ---------------- General ^^^^^^^ - Dropped python 3.3 support. [#542] - Dropped numpy 1.8 support. Minimal required version is now numpy 1.9. [#542] - Dropped support for astropy 1.x versions. Minimal required version is now astropy 2.0. [#575] - Dropped scipy 0.15 support. Minimal required version is now scipy 0.16. [#576] - Explicitly require six as dependency. [#601] New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - Added ``BoundingBox`` class, used when defining apertures. [#481] - Apertures now have ``__repr__`` and ``__str__`` defined. [#493] - Improved plotting of annulus apertures using Bezier curves. [#494] - Rectangular apertures now use the true minimal bounding box. [#507] - Elliptical apertures now use the true minimal bounding box. [#508] - Added a ``to_sky`` method for pixel apertures. [#512] - ``photutils.background`` - Mesh rejection now also applies to pixels that are masked during sigma clipping. [#544] - ``photutils.datasets`` - Added new ``make_wcs`` and ``make_imagehdu`` functions. [#527] - Added new ``show_progress`` keyword to the ``load_*`` functions. [#590] - ``photutils.isophote`` - Added a new ``photutils.isophote`` subpackage to provide tools to fit elliptical isophotes to a galaxy image. [#532, #603] - ``photutils.segmentation`` - Added a ``cmap`` method to ``SegmentationImage`` to generate a random matplotlib colormap. [#513] - Added ``sky_centroid`` and ``sky_centroid_icrs`` source properties. [#592] - Added new source properties representing the sky coordinates of the bounding box corner vertices (``sky_bbox_ll``, ``sky_bbox_ul`` ``sky_bbox_lr``, and ``sky_bbox_ur``). [#592] - Added new ``SourceCatalog`` class to hold the list of ``SourceProperties``. [#608] - The ``properties_table`` function is now deprecated. Use the ``SourceCatalog.to_table()`` method instead. [#608] - ``photutils.psf`` - Uncertainties on fitted parameters are added to the final table. [#516] - Fitted results of any free parameter are added to the final table. [#471] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - The ``ApertureMask`` ``apply()`` method has been renamed to ``multiply()``. [#481]. - The ``ApertureMask`` input parameter was renamed from ``mask`` to ``data``. [#548] - Removed the ``pixelwise_errors`` keyword from ``aperture_photometry``. [#489] - ``photutils.background`` - The ``Background2D`` keywords ``exclude_mesh_method`` and ``exclude_mesh_percentile`` were removed in favor of a single keyword called ``exclude_percentile``. [#544] - Renamed ``BiweightMidvarianceBackgroundRMS`` to ``BiweightScaleBackgroundRMS``. [#547] - Removed the ``SigmaClip`` class. ``astropy.stats.SigmaClip`` is a direct replacement. [#569] - ``photutils.datasets`` - The ``make_poisson_noise`` function was renamed to ``apply_poisson_noise``. [#527] - The ``make_random_gaussians`` function was renamed to ``make_random_gaussians_table``. The parameter ranges must now be input as a dictionary. [#527] - The ``make_gaussian_sources`` function was renamed to ``make_gaussian_sources_image``. [#527] - The ``make_random_models`` function was renamed to ``make_random_models_table``. [#527] - The ``make_model_sources`` function was renamed to ``make_model_sources_image``. [#527] - The ``unit``, ``hdu``, ``wcs``, and ``wcsheader`` keywords in ``photutils.datasets`` functions were removed. [#527] - ``'photutils-datasets'`` was added as an optional ``location`` in the ``get_path`` function. This option is used as a fallback in case the ``'remote'`` location (astropy data server) fails. [#589] - ``photutils.detection`` - The ``daofind`` and ``irafstarfinder`` functions were removed. [#588] - ``photutils.psf`` - ``IterativelySubtractedPSFPhotometry`` issues a "no sources detected" warning only on the first iteration, if applicable. [#566] - ``photutils.segmentation`` - The ``'icrs_centroid'``, ``'ra_icrs_centroid'``, and ``'dec_icrs_centroid'`` source properties are deprecated and are no longer default columns returned by ``properties_table``. [#592] - The ``properties_table`` function now returns a ``QTable``. [#592] - ``photutils.utils`` - The ``background_color`` keyword was removed from the ``random_cmap`` function. [#528] - Deprecated unused ``interpolate_masked_data()``. [#526, #611] Bug Fixes ^^^^^^^^^ - ``photutils.segmentation`` - Fixed ``deblend_sources`` so that it correctly deblends multiple sources. [#572] - Fixed a bug in calculation of the ``sky_centroid_icrs`` (and deprecated ``icrs_centroid``) property where the incorrect pixel origin was being passed. [#592] - ``photutils.utils`` - Added a check that ``data`` and ``bkg_error`` have the same units in ``calc_total_error``. [#537] 0.3.2 (2017-03-31) ------------------ General ^^^^^^^ - Fixed file permissions in the released source distribution. 0.3.1 (2017-03-02) ------------------ General ^^^^^^^ - Dropped numpy 1.7 support. Minimal required version is now numpy 1.8. [#327] - ``photutils.datasets`` - The ``load_*`` functions that use remote data now retrieve the data from ``data.astropy.org`` (the astropy data repository). [#472] Bug Fixes ^^^^^^^^^ - ``photutils.background`` - Fixed issue with ``Background2D`` with ``edge_method='pad'`` that occurred when unequal padding needed to be applied to each axis. [#498] - Fixed issue with ``Background2D`` that occurred when zero padding needed to apply along only one axis. [#500] - ``photutils.geometry`` - Fixed a bug in ``circular_overlap_grid`` affecting 32-bit machines that could cause errors circular aperture photometry. [#475] - ``photutils.psf`` - Fixed a bug in how ``FittableImageModel`` represents its center. [#460] - Fix bug which modified user's input table when doing forced photometry. [#485] 0.3 (2016-11-06) ---------------- New Features ^^^^^^^^^^^^ - ``photutils.aperture`` - Added new ``origin`` keyword to aperture ``plot`` methods. [#395] - Added new ``id`` column to ``aperture_photometry`` output table. [#446] - Added ``__len__`` method for aperture classes. [#446] - Added new ``to_mask`` method to ``PixelAperture`` classes. [#453] - Added new ``ApertureMask`` class to generate masks from apertures. [#453] - Added new ``mask_area()`` method to ``PixelAperture`` classes. [#453] - The ``aperture_photometry()`` function now accepts a list of aperture objects. [#454] - ``photutils.background`` - Added new ``MeanBackground``, ``MedianBackground``, ``MMMBackground``, ``SExtractorBackground``, ``BiweightLocationBackground``, ``StdBackgroundRMS``, ``MADStdBackgroundRMS``, and ``BiweightMidvarianceBackgroundRMS`` classes. [#370] - Added ``axis`` keyword to new background classes. [#392] - Added new ``removed_masked``, ``meshpix_threshold``, and ``edge_method`` keywords for the 2D background classes. [#355] - Added new ``std_blocksum`` function. [#355] - Added new ``SigmaClip`` class. [#423] - Added new ``BkgZoomInterpolator`` and ``BkgIDWInterpolator`` classes. [#437] - ``photutils.datasets`` - Added ``load_irac_psf`` function. [#403] - ``photutils.detection`` - Added new ``make_source_mask`` convenience function. [#355] - Added ``filter_data`` function. [#398] - Added ``DAOStarFinder`` and ``IRAFStarFinder`` as OOP interfaces for ``daofind`` and ``irafstarfinder``, respectively, which are now deprecated. [#379] - ``photutils.psf`` - Added ``BasicPSFPhotometry``, ``IterativelySubtractedPSFPhotometry``, and ``DAOPhotPSFPhotometry`` classes to perform PSF photometry in crowded fields. [#427] - Added ``DAOGroup`` and ``DBSCANGroup`` classes for grouping overlapping sources. [#369] - ``photutils.psf_match`` - Added ``create_matching_kernel`` and ``resize_psf`` functions. Also, added ``CosineBellWindow``, ``HanningWindow``, ``SplitCosineBellWindow``, ``TopHatWindow``, and ``TukeyWindow`` classes. [#403] - ``photutils.segmentation`` - Created new ``photutils.segmentation`` subpackage. [#442] - Added ``copy`` and ``area`` methods and an ``areas`` property to ``SegmentationImage``. [#331] API Changes ^^^^^^^^^^^ - ``photutils.aperture`` - Removed the ``effective_gain`` keyword from ``aperture_photometry``. Users must now input the total error, which can be calculated using the ``calc_total_error`` function. [#368] - ``aperture_photometry`` now outputs a ``QTable``. [#446] - Renamed ``source_id`` keyword to ``indices`` in the aperture ``plot()`` method. [#453] - Added ``mask`` and ``unit`` keywords to aperture ``do_photometry()`` methods. [#453] - ``photutils.background`` - For the background classes, the ``filter_shape`` keyword was renamed to ``filter_size``. The ``background_low_res`` and ``background_rms_low_res`` class attributes were renamed to ``background_mesh`` and ``background_rms_mesh``, respectively. [#355, #437] - The ``Background2D`` ``method`` and ``backfunc`` keywords have been removed. In its place one can input callable objects via the ``sigma_clip``, ``bkg_estimator``, and ``bkgrms_estimator`` keywords. [#437] - The interpolator to be used by the ``Background2D`` class can be input as a callable object via the new ``interpolator`` keyword. [#437] - ``photutils.centroids`` - Created ``photutils.centroids`` subpackage, which contains the ``centroid_com``, ``centroid_1dg``, and ``centroid_2dg`` functions. These functions now return a two-element numpy ndarray. [#428] - ``photutils.detection`` - Changed finding algorithm implementations (``daofind`` and ``starfind``) from functional to object-oriented style. Deprecated old style. [#379] - ``photutils.morphology`` - Created ``photutils.morphology`` subpackage. [#428] - Removed ``marginalize_data2d`` function. [#428] - Moved ``cutout_footprint`` from ``photutils.morphology`` to ``photutils.utils``. [#428] - Added a function to calculate the Gini coefficient (``gini``). [#343] - ``photutils.psf`` - Removed the ``effective_gain`` keyword from ``psf_photometry``. Users must now input the total error, which can be calculated using the ``calc_total_error`` function. [#368] - ``photutils.segmentation`` - Removed the ``effective_gain`` keyword from ``SourceProperties`` and ``source_properties``. Users must now input the total error, which can be calculated using the ``calc_total_error`` function. [#368] - ``photutils.utils`` - Renamed ``calculate_total_error`` to ``calc_total_error``. [#368] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed a bug in ``aperture_photometry`` so that single-row output tables do not return a multidimensional column. [#446] - ``photutils.centroids`` - Fixed a bug in ``centroid_1dg`` and ``centroid_2dg`` that occurred when the input data contained invalid (NaN or inf) values. [#428] - ``photutils.segmentation`` - Fixed a bug in ``SourceProperties`` where ``error`` and ``background`` units were sometimes dropped. [#441] 0.2.2 (2016-07-06) ------------------ General ^^^^^^^ - Dropped numpy 1.6 support. Minimal required version is now numpy 1.7. [#327] - Fixed configparser for Python 3.5. [#366, #384] Bug Fixes ^^^^^^^^^ - ``photutils.detection`` - Fixed an issue to update segmentation image slices after deblending. [#340] - Fixed source deblending to pass the pixel connectivity to the watershed algorithm. [#347] - SegmentationImage properties are now cached instead of recalculated, which significantly improves performance. [#361] - ``photutils.utils`` - Fixed a bug in ``pixel_to_icrs_coords`` where the incorrect pixel origin was being passed. [#348] 0.2.1 (2016-01-15) ------------------ Bug Fixes ^^^^^^^^^ - ``photutils.background`` - Added more robust version checking of Astropy. [#318] - ``photutils.detection`` - Added more robust version checking of Astropy. [#318] - ``photutils.segmentation`` - Fixed issue where ``SegmentationImage`` slices were not being updated. [#317] - Added more robust version checking of scikit-image. [#318] 0.2 (2015-12-31) ---------------- General ^^^^^^^ - Photutils has the following requirements: - Python 2.7 or 3.3 or later - Numpy 1.6 or later - Astropy v1.0 or later New Features ^^^^^^^^^^^^ - ``photutils.detection`` - ``find_peaks`` now returns an Astropy Table containing the (x, y) positions and peak values. [#240] - ``find_peaks`` has new ``mask``, ``error``, ``wcs`` and ``subpixel`` precision options. [#244] - ``detect_sources`` will now issue a warning if the filter kernel is not normalized to 1. [#298] - Added new ``deblend_sources`` function, an experimental source deblender. [#314] - ``photutils.morphology`` - Added new ``GaussianConst2D`` (2D Gaussian plus a constant) model. [#244] - Added new ``marginalize_data2d`` function. [#244] - Added new ``cutout_footprint`` function. [#244] - ``photutils.segmentation`` - Added new ``SegmentationImage`` class. [#306] - Added new ``check_label``, ``keep_labels``, and ``outline_segments`` methods for modifying ``SegmentationImage``. [#306] - ``photutils.utils`` - Added new ``random_cmap`` function to generate a colormap comprised of random colors. [#299] - Added new ``ShepardIDWInterpolator`` class to perform Inverse Distance Weighted (IDW) interpolation. [#307] - The ``interpolate_masked_data`` function can now interpolate higher-dimensional data. [#310] API Changes ^^^^^^^^^^^ - ``photutils.segmentation`` - The ``relabel_sequential``, ``relabel_segments``, ``remove_segments``, ``remove_border_segments``, and ``remove_masked_segments`` functions are now ``SegmentationImage`` methods (with slightly different names). [#306] - The ``SegmentProperties`` class has been renamed to ``SourceProperties``. Likewise, the ``segment_properties`` function has been renamed to ``source_properties``. [#306] - The ``segment_sum`` and ``segment_sum_err`` attributes have been renamed to ``source_sum`` and ``source_sum_err``, respectively. [#306] - The ``background_atcentroid`` attribute has been renamed to ``background_at_centroid``. [#306] Bug Fixes ^^^^^^^^^ - ``photutils.aperture`` - Fixed an issue where ``np.nan`` or ``np.inf`` were not properly masked. [#267] - ``photutils.geometry`` - ``overlap_area_triangle_unit_circle`` handles correctly a corner case in some i386 systems where the area of the aperture was not computed correctly. [#242] - ``rectangular_overlap_grid`` and ``elliptical_overlap_grid`` fixes to normalization of subsampled pixels. [#265] - ``overlap_area_triangle_unit_circle`` handles correctly the case where a line segment intersects at a triangle vertex. [#277] Other Changes and Additions ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - Updated astropy-helpers to v1.1. [#302] 0.1 (2014-12-22) ---------------- Photutils 0.1 was released on December 22, 2014. It requires Astropy version 0.4 or later. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/CITATION.md0000644000175100001660000000011214755160622014707 0ustar00runnerdockerSee https://github.com/astropy/photutils/blob/main/photutils/CITATION.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/CODE_OF_CONDUCT.rst0000644000175100001660000000025714755160622016174 0ustar00runnerdockerPhotutils is an `Astropy `_ affiliated package. We follow the `Astropy Community Code of Conduct `_. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/CONTRIBUTING.rst0000644000175100001660000001170714755160622015630 0ustar00runnerdockerContributing to Photutils ========================= Reporting Issues ---------------- When opening an issue to report a problem, please try to provide a minimal code example that reproduces the issue. Also, please include details of the operating system and the Python, NumPy, Astropy, and Photutils versions you are using. Contributing code ----------------- Contributions to Photutils are done via pull requests from GitHub users' forks of the `Photutils repository `_. If you're new to this style of development, you'll want to read the `Astropy Development documentation `_. Once you open a pull request (which should be opened against the ``main`` branch, not against any other branch), please make sure that you include the following: - **Code**: the code you are adding, which should follow as much as possible the `Astropy coding guidelines `_. - **Tests**: these are either tests to ensure code that previously failed now works (regression tests) or tests that cover as much as possible of the new functionality to make sure it doesn't break in the future. The tests are also used to ensure consistent results on all platforms, since we run these tests on many platforms/configurations. For more information about how to write tests, see the `Astropy testing guidelines `_. - **Documentation**: if you are adding new functionality, be sure to include a description in the main documentation (in ``docs/``). For more information, please see the detailed `Astropy documentation guidelines `_. - **Changelog entry**: if you are fixing a bug or adding new functionality, you should add an entry to the ``CHANGES.rst`` file that includes the PR number and if possible the issue number (if you are opening a pull request you may not know this yet, but you can add it once the pull request is open). If you're not sure where to put the changelog entry, wait until a maintainer has reviewed your PR and assigned it to a milestone. You do not need to include a changelog entry for fixes to bugs introduced in the developer version and therefore are not present in the stable releases. In general, you do not need to include a changelog entry for minor documentation or test updates. Only user-visible changes (new features/API changes, fixed issues) need to be mentioned. If in doubt, ask the core maintainer reviewing your changes. Checklist for Contributed Code ------------------------------ A pull request for a new feature will be reviewed to see if it meets the following requirements. For any pull request, a Photutils maintainer can help to make sure that the pull request meets the requirements for inclusion in the package. **Scientific Quality** (when applicable) * Is the submission relevant to this package? * Are references included to the original source for the algorithm? * Does the code perform as expected? * Has the code been tested against previously existing implementations? **Code Quality** * Are the `Astropy coding guidelines `_ followed? * Are there dependencies other than the Astropy core, the Python Standard Library, and NumPy? - Are additional dependencies handled appropriately? - Do functions and classes that require additional dependencies raise an `ImportError` if they are not present? **Testing** * Are the `Astropy testing guidelines `_ followed? * Are the inputs to the functions and classes sufficiently tested? * Are there tests for any exceptions raised? * Are there tests for the expected performance? * Are the sources for the tests documented? * Are the tests that require an `optional dependency `_ marked as such? * Does "``tox -e test``" run without failures? **Documentation** * Are the `Astropy documentation guidelines `_ followed? * Is there a `docstring `_ in the functions and classes describing: - What the code does? - The format of the inputs of the function or class? - The format of the outputs of the function or class? - References to the original algorithms? - Any exceptions which are raised? - An example of running the code? * Is there any information needed to be added to the docs to describe the function or class? * Does the documentation build without errors or warnings? * If applicable, has an entry been added into the changelog? **License** * Is the Photutils license included at the top of the file? * Are there any conflicts with this code and existing codes? ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/LICENSE.rst0000644000175100001660000000274714755160622015007 0ustar00runnerdockerCopyright (c) 2011-2025, Photutils Developers All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1739907483.727927 photutils-2.2.0/PKG-INFO0000644000175100001660000001551114755160634014264 0ustar00runnerdockerMetadata-Version: 2.2 Name: photutils Version: 2.2.0 Summary: An Astropy package for source detection and photometry Author-email: Photutils Developers License: Copyright (c) 2011-2025, Photutils Developers All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Project-URL: Homepage, https://github.com/astropy/photutils Project-URL: Documentation, https://photutils.readthedocs.io/en/stable/ Keywords: astronomy,astrophysics,photometry,aperture,psf,source detection,background,segmentation,centroids,isophote,morphology,radial profiles Classifier: Intended Audience :: Science/Research Classifier: License :: OSI Approved :: BSD License Classifier: Natural Language :: English Classifier: Operating System :: OS Independent Classifier: Programming Language :: Cython Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Topic :: Scientific/Engineering :: Astronomy Requires-Python: >=3.11 Description-Content-Type: text/x-rst License-File: LICENSE.rst Requires-Dist: numpy>=1.24 Requires-Dist: astropy>=5.3 Requires-Dist: scipy>=1.10 Provides-Extra: all Requires-Dist: matplotlib>=3.7; extra == "all" Requires-Dist: regions>=0.9; extra == "all" Requires-Dist: scikit-image>=0.20; extra == "all" Requires-Dist: gwcs>=0.20; extra == "all" Requires-Dist: bottleneck; extra == "all" Requires-Dist: tqdm; extra == "all" Requires-Dist: rasterio; extra == "all" Requires-Dist: shapely; extra == "all" Provides-Extra: test Requires-Dist: pytest-astropy>=0.11; extra == "test" Requires-Dist: pytest-xdist>=2.5.0; extra == "test" Provides-Extra: docs Requires-Dist: photutils[all]; extra == "docs" Requires-Dist: sphinx; extra == "docs" Requires-Dist: sphinx_design; extra == "docs" Requires-Dist: sphinx-astropy[confv2]>=1.9.1; extra == "docs" ========= Photutils ========= |PyPI Version| |Conda Version| |PyPI Downloads| |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 an a `coordinated package `_ of `Astropy`_ and integrates well with other Astropy packages, making it a powerful tool for astronomical image analysis. Please see the `online documentation `_ for `installation instructions `_ and usage information. Citing Photutils ---------------- |Zenodo| If you use Photutils for a project that leads to a publication, whether directly or as a dependency of another package, please include the following acknowledgment:: This research made use of Photutils, an Astropy package for detection and photometry of astronomical sources (Bradley et al. ). where (Bradley et al. ) is a citation to the `Zenodo record `_ of the Photutils version that was used. We also encourage citations in the main text wherever appropriate. Please see the `CITATION `_ file for details and an example BibTeX entry. License ------- Photutils is licensed under a 3-clause BSD license. Please see the `LICENSE `_ file for details. .. |PyPI Version| image:: https://img.shields.io/pypi/v/photutils.svg?logo=pypi&logoColor=white&label=PyPI :target: https://pypi.org/project/photutils/ :alt: PyPI Latest Release .. |Conda Version| image:: https://img.shields.io/conda/vn/conda-forge/photutils :target: https://anaconda.org/conda-forge/photutils :alt: Conda Latest Release .. |PyPI Downloads| image:: https://img.shields.io/pypi/dm/photutils?label=PyPI%20Downloads :target: https://pypistats.org/packages/photutils :alt: PyPI Downloads .. |Astropy| image:: https://img.shields.io/badge/powered%20by-AstroPy-orange.svg?style=flat :target: https://www.astropy.org/ :alt: Powered by Astropy .. |Zenodo| image:: https://zenodo.org/badge/2640766.svg :target: https://zenodo.org/doi/10.5281/zenodo.596036 :alt: Zenodo Latest DOI .. |CI Status| image:: https://github.com/astropy/photutils/workflows/CI%20Tests/badge.svg# :target: https://github.com/astropy/photutils/actions :alt: CI Status .. |Codecov Status| image:: https://img.shields.io/codecov/c/github/astropy/photutils?logo=codecov :target: https://codecov.io/gh/astropy/photutils :alt: Coverage Status .. |Stable RTD Status| image:: https://img.shields.io/readthedocs/photutils/latest.svg?logo=read%20the%20docs&logoColor=white&label=Docs&version=stable :target: https://photutils.readthedocs.io/en/stable/ :alt: Stable Documentation Status .. |Latest RTD Status| image:: https://img.shields.io/readthedocs/photutils/latest.svg?logo=read%20the%20docs&logoColor=white&label=Docs&version=latest :target: https://photutils.readthedocs.io/en/latest/ :alt: Latest Documentation Status .. _Astropy: https://www.astropy.org/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/README.rst0000644000175100001660000000671114755160622014655 0ustar00runnerdocker========= Photutils ========= |PyPI Version| |Conda Version| |PyPI Downloads| |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 an a `coordinated package `_ of `Astropy`_ and integrates well with other Astropy packages, making it a powerful tool for astronomical image analysis. Please see the `online documentation `_ for `installation instructions `_ and usage information. Citing Photutils ---------------- |Zenodo| If you use Photutils for a project that leads to a publication, whether directly or as a dependency of another package, please include the following acknowledgment:: This research made use of Photutils, an Astropy package for detection and photometry of astronomical sources (Bradley et al. ). where (Bradley et al. ) is a citation to the `Zenodo record `_ of the Photutils version that was used. We also encourage citations in the main text wherever appropriate. Please see the `CITATION `_ file for details and an example BibTeX entry. License ------- Photutils is licensed under a 3-clause BSD license. Please see the `LICENSE `_ file for details. .. |PyPI Version| image:: https://img.shields.io/pypi/v/photutils.svg?logo=pypi&logoColor=white&label=PyPI :target: https://pypi.org/project/photutils/ :alt: PyPI Latest Release .. |Conda Version| image:: https://img.shields.io/conda/vn/conda-forge/photutils :target: https://anaconda.org/conda-forge/photutils :alt: Conda Latest Release .. |PyPI Downloads| image:: https://img.shields.io/pypi/dm/photutils?label=PyPI%20Downloads :target: https://pypistats.org/packages/photutils :alt: PyPI Downloads .. |Astropy| image:: https://img.shields.io/badge/powered%20by-AstroPy-orange.svg?style=flat :target: https://www.astropy.org/ :alt: Powered by Astropy .. |Zenodo| image:: https://zenodo.org/badge/2640766.svg :target: https://zenodo.org/doi/10.5281/zenodo.596036 :alt: Zenodo Latest DOI .. |CI Status| image:: https://github.com/astropy/photutils/workflows/CI%20Tests/badge.svg# :target: https://github.com/astropy/photutils/actions :alt: CI Status .. |Codecov Status| image:: https://img.shields.io/codecov/c/github/astropy/photutils?logo=codecov :target: https://codecov.io/gh/astropy/photutils :alt: Coverage Status .. |Stable RTD Status| image:: https://img.shields.io/readthedocs/photutils/latest.svg?logo=read%20the%20docs&logoColor=white&label=Docs&version=stable :target: https://photutils.readthedocs.io/en/stable/ :alt: Stable Documentation Status .. |Latest RTD Status| image:: https://img.shields.io/readthedocs/photutils/latest.svg?logo=read%20the%20docs&logoColor=white&label=Docs&version=latest :target: https://photutils.readthedocs.io/en/latest/ :alt: Latest Documentation Status .. _Astropy: https://www.astropy.org/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/codecov.yml0000644000175100001660000000031514755160622015325 0ustar00runnerdockercomment: off codecov: branch: main coverage: status: project: default: target: auto # this allows a small drop from the previous base commit coverage threshold: 0.05% ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6769264 photutils-2.2.0/docs/0000755000175100001660000000000014755160634014114 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/Makefile0000644000175100001660000001072714755160622015560 0ustar00runnerdocker# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest #This is needed with git because git doesn't create a dir if it's empty $(shell [ -d "_static" ] || mkdir -p _static) help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" clean: -rm -rf $(BUILDDIR) -rm -rf api -rm -rf generated html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Astropy.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Astropy.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/Astropy" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Astropy" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." make -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: @echo "Run 'pytest' in the root directory to run doctests " \ @echo "in the documentation." ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6769264 photutils-2.2.0/docs/_static/0000755000175100001660000000000014755160634015542 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/_static/custom.css0000644000175100001660000000005014755160622017556 0ustar00runnerdocker.field-list ul { padding-left: 2em; } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/_static/photutils_logo.ico0000644000175100001660000004046614755160622021320 0ustar00runnerdocker@? A(@~ ?ÃÃĒp/Ēp/ŗw1Ēp/Šp/Šo/¨o.¨o.§n.§n-Ļm-Ļm-Ĩl,Ĩl,¤k,Ŗk+Ŗj+ĸj+ i.ĸj,Ąi)Ēr0Ēp0Ēq0Ēq0Ēp/Ēp/§n0Ēq/Šo/Šo.¨o.§n.§n-Ļm-Ļm-Ĩl,Ĩl,¤k,¤k+Ŗj,Ąi)Ąi* h) h)Ąh(Ąh)Ÿg)ĒU+Ģq0Ģq0Ģr0Ģq0Ēq0ą€@Ēp0Ēp/¨o0Ēq/Šo/<Šo.`¨o.}¨n.‘§n.›Ļm-Ļm-—Ĩl,ˆĨl,p¤k,Q¤k+.Ŗj, Ąi*Ąi* h)Ļm.žg(f(™e#•ef'šf)Ģq0Ģq0Ģq0Ģq0Ģq0Ŧr1Ģq0ĄUĒp0=Ēp/ŠĒp/ĘŠo/ōŠo.˙¨o.˙§n.˙§n-˙Ļm-˙Ļm-˙Ĩl,˙Ĩl,˙¤k,˙¤k+˙Ŗj+˙ĸj+čĸi*ģĄi*| h)6ʄLžg(žf(œe'œd&“^!˜b#›d&šc%ĒUUĢq0Šp.Ģq0Ģq0¯t2Ģq0Ŧr1Ģq0tĒp0ÖĒp/˙Šp/˙Šo/˙¨o.˙§n.ũ§n.ûĻm-ûĻm-ûĨl,ûĨl,û¤k,û¤k+ûŖj+ûĸj+üĸj*˙Ąi*˙Ąi*˙ h)˙ h)˙Ÿg)מg(ƒžf(&œe'œe&šc&™c%œe%f%™b%˜c&Ŧr1Ģq0Ģq0Ģq0Ēp0Ģq0˛y4Ģq0wĒq0ėĒp/˙Šp/˙Šo/ũ¨o.û¨n.ü§n.ū§m-˙Ļm-˙Ĩl-˙Ĩl,˙¤l,˙¤k,˙Ŗk+˙Ŗj+˙ĸj+˙ĸi*˙Ąi*˙ h)ū h)üŸg)ûŸg(ūžg(˙žf(˙f'ūœe'¯œe';šc&šc%˜b$—a$˜a$˜a$—a$Šo/Ēp0Ēp0Ēq0Šn-Ēp0Ģq0=Ģq0ŲĒp/˙Ēp/˙Šo/ú¨o.ũ¨n.˙Ļn/˙Ĩm/˙¤m/˙¤l.˙Ĩl-˙¤l,˙¤k,˙Ŗk+˙Ŗj+˙ĸj+˙ĸi*˙Ąi*˙Ąh*˙ h)˙Ÿh)˙Ÿg)˙žg(˙žf(˙f'ũe'ûœe'˙œd&˙›d&˙šc&´šc%/˜b$˜b%–`#˜b$§r'•_#“`"Ģq0Ēp/Ēp/Ēp/Ŧq1Ēp/Ģq0}Ēp0˙Ēp/˙Šo/úŠo.ū¨o.˙Ĩn1˙Ļn.˙Ŧn%˙°n ˙°n˙­m!˙§l)˙Ąk.˙ĸj,˙ĸj+˙ĸj*˙Ąi*˙Ąi*˙ h)˙ h)˙Ÿg)˙žg(˙žf(˙f(˙e'˙œe'˙œd'˙›d&˙šd&ûšc&ũ™c%˙™b%˙˜b$’˜b% –`#•_"“]"”^"”^"“^!Ļo,Šp/Ēp/Ēp/Ŧq/Šr-Ēp0¨Ēp/˙Šp/ūŠo/ü¨o.˙§n/˙Ļn/˙˛o˙Šn)˙ŒhN˙wdf˙sck˙e[˙™i8˙Žl˙§j$˙Ÿi-˙Ąi*˙ h)˙ h)˙Ÿg)˙Ÿg(˙žf(˙f(˙f'˙œe'˙œe'˙›d&˙›d&˙šc&˙šc%˙™b%˙˜b%û˜a$ū—a$˙—`$ã–`#E•_"“^“]"“]!’]!‘\ Šo/Šp/Ēp/Ģr/Ĩn2Ēp/ˇŠp/˙Šo/ú¨o.ū¨n.˙Ļn0˙Ēn)˙­n$˙jd{˙=Z°˙G\¤˙W^˙Z_Š˙O]˜˙=Z¯˙J\ž˙fB˙Ģj˙h,˙Ÿh)˙Ÿg(˙žg(˙žf(˙f'˙e'˙œe'˙›d&˙›d&˙šc&˙šc%˙™b%˙™b%˙˜b$˙—a$˙—a$˙–`#ú–`#˙•_#˙•_"†“] “^"’]!’]!‘\ \§o/Ēp0§n,Šo/Šp/ĢŠo/˙¨o.ų¨n.˙§n.˙Ĩm/˙Ģn(˙ k3˙=\ĩ˙NWŒ˙–h<˙Ŧl ˙°m˙°n˙Žn˙¤k*˙ubd˙2WŊ˙m`m˙Ēi˙œf+˙žf(˙f(˙e'˙œe'˙œd&˙›d&˙šc&˙šc%˙™c%˙™b%˙˜b$˙˜a$˙—a$˙–`#˙–`#˙•_#˙•_"ü”^"ũ”^"˙“^!š“^" ’\!‘\ [ ZZ¨n.§n-¨o.Šo.Šo/„Šo/˙¨o.ú§n.˙§n-˙Ļm.˙§m+˙¤l.˙6[ž˙oYY˙ŌX˙íŨÉ˙ĸl1˙œd'˙—]˙–[˙š_˙Ģj˙Ąi(˙6Xĩ˙k_m˙¨g˙›e*˙œe'˙œe'˙›d&˙›d&˙šc&˙™c%˙™b%˙˜b%˙˜a$˙—a$˙—`#˙–`#˙–`#˙•_"˙”_"˙”^"˙“^!˙“]!ú’]!˙’\ ؑ\ [ ZZŽYY§n-¨n.¨o.¨o.¨o.F¨o.˙¨n.ũ§n.ūĻm-˙Ļm-˙Ŗl/˙°n˙K^ĸ˙f[m˙Ɖ<˙÷ųū˙Ŋ—k˙™\˙ŦzB˙Édž˙Îą‘˙ē‘d˙˜a$˙Ÿc˙Ąh'˙1Vŧ˙ŠcA˙Ąe ˙šd(˙›d&˙šc&˙šc%˙™b%˙˜b%˙˜a$˙—a$˙—a$˙–`#˙–`#˙•_#˙•_"˙”^"˙“^"˙“]!˙’]!˙’\ ˙‘\ ú‘\ ˙[į[ ZŽYXŒX‹V§l,Ĩl,¨o.Šo/Šo/¨n.â§n.˙§m-üĻm-˙Ļm-˙¤l.˙Ģm#˙‡fR˙@\Ž˙Šd˙éß×˙ÍŽŒ˙N˙ĮĨ€˙ú÷ô˙äÔÂ˙ŨĘ´˙ņéā˙íâÖ˙¤t@˙¤a˙uaa˙EYž˙Ļe˙˜c)˙šc%˙™b%˙™b%˙˜b$˙˜a$˙—a$˙–`#˙–`#˙•_#˙•_"˙”^"˙”^"˙“]!˙’]!˙’]!˙‘\ ˙‘\ ˙[ ˙[úZ˙ZéŽYYXŒX‹WŽXĻm-Ĩm-§n.§n.§n.…§n-˙Ļm-úĻm-˙Ĩl,˙Ĩl,˙ĸk/˙°m˙U_’˙n]b˙ģƒ>˙ōíč˙Ÿe%˙˛…R˙ûų÷˙­}H˙N˙Q ˙•Y˙Ōē˙ķėä˙g,˙c ˙;X­˙b5˙œc!˙˜b%˙˜b$˙˜a$˙—a$˙—`#˙–`#˙•_#˙•_"˙”_"˙”^"˙“^!˙“]!˙’]!˙’\ ˙‘\!˙Ž[#˙Ž["˙ŽZ ˙Z˙ŽYúŽY˙YߍXŒXŒWŠV‰U¤l+§m-§m-§m-Ļm-öĻm-˙Ĩl,ūĨl,˙¤k,˙¤k+˙ĸj-˙¨k#˙E]Š˙‰\3˙Ěj˙éŨĐ˙’R ˙ŲÃĒ˙Đļ˜˙ĸl0˙ßÍš˙âŌĀ˙Ŧ~I˙ŒM˙äÖÅ˙Ė´š˙W˙N\–˙t^Z˙Ąc˙–a&˙—a$˙—`$˙–`#˙–`#˙•_#˙•_"˙”^"˙“^!˙“]!˙’]!˙’\ ˙‘\ ˙[!˙‘[˙™[˙™[˙“Z˙ŒY!˙Y˙XúŒX˙ŒWŌXŒV‹W‹UˆT¨o,Ĩm+Ļm-Ļm-Ļm-…Ĩl-˙Ĩl,ú¤l,˙¤k,˙Ŗk+˙ĸj+˙ĸj*˙Ąi+˙B\Ģ˙“^&˙ž•f˙ëāÔ˙’S ˙Ķš˙ëāĶ˙ôíæ˙˙˙˙˙˙˙˙˙ëßĶ˙”Y˙¸‘e˙čß×˙ ] ˙X[„˙g\j˙ĸb˙•`&˙–`#˙–`#˙•_#˙”_#˙’_&˙’^%˙’]"˙’]!˙’\!˙‘\ ˙‘\ ˙[ ˙‘[˙ŽZ ˙LUˆ˙?T›˙zX<˙”Y˙ŠX ˙ŒW˙‹WúŠW˙ŠV—ŠV‰V€P†SˆSŖk+Ĩm,Ļm,Ļn,Ĩl,ã¤l,˙¤k,ũŖk+˙Ŗj+˙ĸj+˙ĸi*˙Ąi*˙ĸi(˙AZŠ˙“c2˙Šs6˙öōí˙Ĩr9˙¤p6˙ũüû˙˙˙˙˙öđę˙žšq˙īįŨ˙ži.˙Ē}I˙ėäŪ˙¤d˙SY†˙j\f˙ a˙”`%˙•_#˙•_"˙“_%˙—]˙Ŗe˙Ąc˙”\˙\"˙‘\ ˙[ ˙[˙Z"˙—Z˙AT˜˙PU€˙qWJ˙)Rž˙qVI˙“X˙‰W˙ŠV˙ŠVü‰U˙‰UWˆUˆT†S…R f,¤l,¤l,¤l,M¤k,˙Ŗk+üŖj+˙ĸj+˙ĸi*˙Ąi*˙Ąi*˙žh,˙Ši˙FZĄ˙…eP˙œ[˙ÔŧŖ˙ėâÖ˙”Y˙œf(˙ĩ`˙“Y˙Åσ˙ęßĶ˙ŽR˙ĩŽb˙éāØ˙\˙DXž˙{]K˙œ`˙”_$˙”_"˙’^$˙š^˙ƒ[8˙W^‹˙Y]ƒ˙ˆ[-˙•\˙[!˙Z˙ŽZ ˙“Z˙yX?˙6S¨˙˜Y ˙—X ˙€W/˙)Rŧ˙„V'˙ŒV˙‰U˙‰UũˆU˙‡Tņ‡T‡T‡T‰T„P¤k(Ŗk+Ŗk+¤k+œŖk+˙ĸj+ûĸj*˙Ąi*˙Ąi*˙ h)˙ h)˙g,˙Ēi˙b^y˙[]„˙Ēg˙—a&˙ß͸˙ôīč˙ČLj˙¸’g˙ÚĮ°˙øõņ˙Š|I˙‡G˙ÛČŗ˙ÕŊ ˙…Q˙8X˛˙–_"˙•_"˙”^"˙’^#˙˜]˙y[I˙%HĢ˙rrŽ˙ei˙'JĢ˙‚Z5˙’Z˙Y˙ŒY!˙˜Y ˙WUt˙UUu˙—Y˙†W&˙—Y ˙_Tc˙>R˜˙“V ˙†U˙ˆT˙‡Tú†S˙†SІS…SrD ƒPŖj+Ļl-Šn0Ŗj+Øĸj+˙ĸi*ũĄi*˙ h)˙ h)˙Ÿg)˙Ÿg(˙žf)˙Ÿf%˙—e0˙3Wš˙˜e.˙d"˙”[˙ē”i˙Ūˏ˙å×Į˙Ōģ ˙ m5˙”\˙ŲÆ¯˙ņīđ˙¯p ˙MT„˙ZZ|˙Ÿ_˙‘^%˙“^!˙‘]$˙›`˙>YĒ˙|bU˙æÄ•˙Ū´{˙i^j˙GX˜˙š]˙ŒY"˙‹X!˙–X ˙ET˙kVR˙P˙‡S˙L˙’W˙4R¨˙pTB˙T˙†S˙†S˙†Rü…R˙…RE„R„RƒQPĸi*ĸj*ĸj*#ĸi*ûĄi*˙Ąh*ū h)˙Ÿh)˙Ÿg)˙žg(˙žf(˙f'˙›e*˙Ļf˙j^k˙;WŦ˙§f˙e$˙X˙U˙‘V˙V˙ŒP˙ŋĄ˙˙˙˙˙āēˆ˙kRJ˙4Wļ˙˜^˙“^"˙’]!˙’]"˙’^#˙šg,˙?^ĩ˙ļ—v˙õņí˙ķčŲ˙ĸŒ~˙B]Ŧ˙d ˙ŒY"˙‹X ˙’W˙>UŸ˙qM/˙š“g˙ÜÍŊ˙ą‘m˙‰K˙gUV˙Bn˙Ā—c˙˛†Q˙2B„˙[Vo˙”W ˙‰W˙ŠW˙ŽX˙4L˜˙’sW˙ÚÆŽ˙‰W˙ĶÁĢ˙­‹f˙ƒH˙4SŦ˙vR2˙ˆR˙„Q˙„Q˙ƒPû‚P˙‚PS‚P‚P‚P€L h) h) h)l h)˙Ÿg)ûŸg(˙žf(˙f(˙f'˙œe'˙œe'˙›d&˙›d&˙šc&˙˜c(˙¤d˙g\k˙1V¸˙YZ˙€^E˙`.˙](˙ŽgB˙o_c˙6L–˙>Vĸ˙](˙—]˙\#˙‘\ ˙‘\ ˙[˙[ ˙Z˙Z˙DT’˙?_¸˙:\¸˙MT‚˙‘X˙‹W˙ŠW˙ŠV˙ŒX˙0G“˙Ē‘z˙¯Š_˙y@˙‹Y!˙×Éģ˙•Z˙QOm˙MQz˙ŽQ˙Q˙ƒP˙‚PüO˙OĮNN‚P~LŸg)Ÿg)Ÿg)†Ÿg(˙žg(ûžf(˙f'˙e'˙œe'˙›d&˙›d&˙šc&˙™c&˙™b%˙™b%˙–a'˙ĸb˙`.˙[Zy˙@W ˙˙/T¸˙^(˙•^ ˙“]"˙’]!˙’]!˙‘\ ˙‘\ ˙[ ˙[˙Ž["˙’X˙Ÿc˙ĸg˙’V˙‹X ˙ŒX˙‹W˙‹W˙ŠV˙ŠV˙‰U˙ˆU˙ˆT˙‡T˙‡S˙†S˙†S˙ƒR˙ŽR˙AQŽ˙fQL˙ˆN ˙ŅĀŽ˙†U˙Ęļž˙¤W˙Įą™˙}H ˙3PĨ˙uM&˙€L˙}L˙}K˙|Kû{J˙{J”{J{JyOšc&šc&šc&}šc&˙™c%û™b%˙˜b$˙–a'˙ĸb˙b[r˙FU’˙ą|6˙­¨Ļ˙¯€C˙OUƒ˙dZi˙^˙]#˙’\ ˙‘\ ˙[ ˙[˙Z˙Z!˙–Z˙xV<˙S\‹˙R_–˙jTN˙“X˙ŠW˙ŠV˙ŠV˙‰U˙‰U˙ˆT˙‡T˙‡T˙†S˙†S˙…R˙…R˙‚Q˙ŽQ˙JQ~˙WN^˙‘X˙Đŋ­˙w@˙Įą˜˙ŊŖ†˙ĀĢ“˙’[˙R˜˙MTƒ˙•W ˙ˆV˙ŠV˙‰V˙‰U˙ˆU˙ˆT˙‡T˙‡S˙†S˙†R˙„R˙„S˙ƒS˙‚P˙ƒP˙‚P˙O˙O˙€N˙€N˙}M˙ˆM˙\MT˙#I­˙Yc˙•Ž˙‰ta˙(KĢ˙rK$˙{I˙‚H˙;O“˙R;5˙ÃĨ~˙ [˙h2˙sB ˙sC˙…Q˙^PV˙:H‚˙|C˙qD˙rC ˙rB ûqB ˙qB …qB qB ’]!ŽYZZZȏZ˙ŒY!ü•Y˙fV]˙1R°˙3RĒ˙PT}˙’W˙‰V˙‰V˙‰U˙ˆU˙ˆT˙‡T˙‡S˙†S˙†S˙…R˙„R˙„Q˙„R˙„R˙‚O˙‚O˙O˙N˙€N˙M˙}M˙‡M˙WM\˙'LŽ˙ŸŒz˙áØĮ˙ááŪ˙–‚n˙*Gž˙zJ˙xI˙€G˙UL[˙7Bv˙žo2˙Åŗ ˙m:˙zK˙m<˙‚N˙m^`˙4Eƒ˙{C˙pC˙qB ˙qB ûpA ˙pA lpA pA ‘Z"ŽYŽYŽYŽYTŽY˙Yû‹X˙“X˙‡W$˙„W(˙“W ˙‰V˙‰U˙‰U˙ˆT˙‡T˙‡T˙†S˙†S˙…R˙„R˙„Q˙ƒQ˙ƒP˙‚P˙‚O˙O˙O˙€N˙€N˙M˙~M˙L˙rM+˙&K¯˙ĄŽz˙æęė˙ëëé˙°˜z˙*MĢ˙VJW˙H˙wH˙yG ˙qG˙-N§˙q?˙ĪžĢ˙ˆ_1˙m:˙„[.˙ÃĻ‚˙vik˙3Fˆ˙zB˙oB˙pA ˙pA üo@ ˙o@ Jo@ o@ [ŒWŒXŒXXˌX˙ŒWü‰W˙ŒV˙ŒV˙‡U˙‰U˙ˆU˙ˆT˙‡T˙†S˙†S˙…R˙„R˙‚Q˙„Q˙†P˙†P˙…P˙‚O˙€O˙~N˙N˙M˙M˙~L˙|L˙‡L˙JKp˙PTy˙Ę­~˙ø¨˙ƒ{˙&K­˙LJg˙H˙vG˙wG˙uG˙E˙9M’˙O<<˙ŖzG˙ȡ¤˙\%˙›zV˙˙˙÷˙fTR˙3E†˙yA˙nA˙oA ūo@ ˙n@ ûn? #n? n? YXŒWŒW‹WE‹W˙ŠWüŠV˙‰V˙‰U˙ˆU˙ˆT˙‡T˙‡S˙†S˙…R˙…R˙ƒR˙‰Q˙ŒQ˙€P˙vP.˙tR4˙xR.˙€Q˙†N ˙‰M˙…M ˙~M˙|L˙}L˙|K˙K ˙eK=˙*O°˙IWŒ˙4Ož˙*F›˙aJ@˙H˙vG˙wG˙vF˙uF˙{E˙ZGE˙5M˜˙s7˙IJ ˙Ŧo˙†_4˙˙øę˙fZ`˙4C˙xA˙m@˙n@ ũn? ˙m? Øi:k=m? YŸg.‹WŠVŠVNJV˙‰UúˆU˙ˆT˙‡T˙‡S˙†S˙†S˙…R˙…R˙ƒQ˙‹Q ˙kPB˙:P˜˙4QĨ˙5Nœ˙.C‹˙+B˙)B’˙5M˜˙HO|˙dMD˙L˙†K˙}K˙zJ˙zJ˙I ˙kJ/˙SHV˙eI6˙I˙}G˙uG˙wF˙vF˙uE˙uE˙sE˙xC˙,LĨ˙bD,˙uA˙žŠ‘˙ËŧĢ˙ŲÂŖ˙YTh˙:Dt˙x?˙l@˙n? ûm> ˙l> ›m> m> i= ŒY‡T‰U‰U‰V‰UōˆT˙‡Tũ‡T˙†S˙†S˙…R˙…R˙„Q˙‚Q˙ŠP ˙YPa˙*P˛˙fPJ˙yI˙†R˙ąŒ^˙ÁĨƒ˙ŊŠ•˙y_O˙RLc˙9Qš˙/O¨˙KMq˙wJ˙„I˙yI˙xI˙|H ˙H˙}G˙vG˙vF˙vF˙uE˙uE˙tE˙tD˙rD˙zB˙QFS˙4J’˙y?˙h9˙i:˙‹Y˙IQ|˙EC^˙v>˙l? ˙m> ül> ˙l= Ml= l= rC ‹WŠVˆUˆTˆTW‡T˙†Sü†S˙…R˙…R˙„Q˙„Q˙ƒQ˙…P˙zP%˙+P˛˙P˙†J˙Œ^*˙ÕÅ´˙¸Ÿ‚˙ |T˙™qC˙|E˙z;˙{>˙nF!˙HO{˙*Oą˙LKk˙H˙|H ˙vH˙vG˙vG˙vF˙vF˙uE˙uE˙tD˙sD˙sC˙rC ˙qC˙wA˙*KĨ˙ZE=˙xA˙kB˙t=˙1G˙XB:˙r=˙k> ũl= ˙k= ãj< j< k= m> ŠT‰VZ‡T†S†S—†R˙…Rú„R˙„Q˙ƒQ˙ƒP˙P˙ˆO ˙dOK˙@O‹˙‹P˙r@ ˙ÁŠŽ˙Ļ„^˙n6˙r<˙‰\*˙ļ›}˙˙Ĩ†e˙Š[%˙z;˙qC˙>O‹˙+MĒ˙nH$˙}F˙tF˙vF˙uE˙uE˙tD˙tD˙sC˙rC ˙rB ˙qB ˙pB˙tA˙bC*˙!Kˇ˙k@˙z=˙hA˙&JŠ˙m>˙l= ˙k= úk=˙j<…j<j<j< j= ˆU†S…R…Q…R…RńQ˙ƒQúƒP˙‚P˙‚O˙O˙„N˙pN3˙4O ˙‡P ˙s?˙ŋϊ˙Ą~U˙p9˙”l>˙ÔÃą˙ }U˙Ÿ{S˙°“s˙ĖēĻ˙ÅŗŸ˙‹_.˙y9˙^KH˙!Mŧ˙aH:˙|E˙sE˙tD˙tD˙sD˙sC ˙rC ˙qB ˙qB ˙pA ˙pA ˙nA˙v?˙QDJ˙"K´˙RCG˙)IŖ˙ICW˙r<˙i= ūj<˙j<öi<i<i<k= ‡S†R„Q„Q„QƒP߂P˙‚PúO˙O˙€N˙~N˙‡M˙4O ˙bNL˙‚F˙Ša4˙ÚĖŧ˙{J˙Š^-˙ßŌÅ˙ŋ¨˙ž§Œ˙¤ƒ^˙ƒV$˙—rG˙×Éē˙ŠŽq˙u9˙mH&˙!Mģ˙dF1˙yD˙rD˙sC ˙rC ˙rB ˙qB ˙qA ˙pA ˙o@ ˙o@ ˙n@ ˙l?˙t>˙]A-˙5G‡˙MBO˙p<˙i= ˙j<úi;˙i;†i;i;j=j<‰P…R„QƒQƒP‚POéO˙€Nú€N˙M˙~M˙‚L ˙pL.˙%Oē˙zM˙y@˙™wQ˙ÛΞ˙Že6˙ }U˙ōîé˙˙˙˙˙˙˙˙˙ûų÷˙“mB˙m:˙Îŧ¨˙Ŧ’w˙r7˙eH4˙%Lą˙sC ˙sC ˙rB ˙qB ˙qA ˙pA ˙pA ˙o@ ˙n@ ˙n? ˙m? ˙m> ˙k> ˙o=˙t;˙p<˙i< ˙j<üi;˙h;âg;g:h:k=h= †S„QƒQ‚PON €NįM˙Mú~M˙~L˙{L˙„K˙^LL˙$Nš˙vM#˙{>˙ˆa5˙ÎŊĒ˙ŊĨŠ˙™uK˙—rH˙—rH˙­o˙ØĘģ˙o<˙p?˙ĶIJ˙„^5˙z<˙@Jz˙FHj˙zA˙oB˙pA ˙pA ˙o@ ˙o@ ˙n? ˙m? ˙m> ˙l> ˙l> ˙k= ˙j= ˙h< ˙h< ˙i;ūh;ũh:˙h:Fh:h:h;i;ƒP‚POO€NM~MØ~L˙}Lú}K˙|K˙yJ˙„J˙`KF˙#Nē˙]MQ˙~A˙u>˙˜vQ˙Ŋ§˙į—˙ÂŦ”˙Į´ž˙‘k@˙tD˙e1˙Ќk˙ĒŽo˙j3˙cG3˙1J–˙vA˙oA˙o@ ˙o@ ˙n? ˙n? ˙m? ˙l> ˙l> ˙k= ˙k= ˙j<˙j<˙i;˙i;˙h:úg:˙g:„g:h:i;h;OO€NMM}K }Kš|K˙|Kũ{Jü{J˙xI˙H˙pI#˙.Mĸ˙6N—˙kJ-˙y<˙r8˙vG˙wJ˙n> ˙vH˙l;˙xK˙Ōô˙Šg@˙o9˙eD)˙.J›˙u@˙n@˙n@ ˙n? ˙m? ˙m> ˙l> ˙l= ˙k= ˙j<˙j<˙i;˙i;˙h;˙h:ųg:˙g9Ģg:i; f9 g:NM~L~L}L}L{J†{J˙zI˙zIúyH˙wH˙{G ˙G˙TIV˙+L¨˙8M“˙\HD˙U#˙—f*˙Ŋ›n˙˙ūō˙āĐē˙Õ¨˙Ŗ}N˙r7˙zB˙0J—˙HF_˙v?˙m? ˙m? ˙m> ˙l> ˙l= ˙k= ˙k=˙j<˙i<˙i;˙h;˙h:ūg:úg9˙f9ˇj<e7f9g9g:}M~L}L}L~K{JzIDyIãyH˙xHūxGûvG˙uF˙~E˙yE˙VHN˙4K“˙,Kĸ˙DXš˙XYw˙~w‚˙ĄœŖ˙mTE˙O6*˙JId˙(J¨˙ ˙l> ˙l> ˙k= ˙k=˙j<˙j<˙i;˙h;˙h:˙g:üg9ũf9˙f9¨e8e8f9f9g9l; |J|K|K|K{JzIxH xG’wG˙wF˙vFũvEûtE˙tE˙{C˙{C˙lD˙SCF˙?Dm˙/>{˙':˙0G‘˙6K˙@Fr˙^B.˙u>˙m? ˙l> ˙l> ˙k= ˙k= ˙j<˙j<˙i;˙i;˙h:˙g:ūg:úf9˙f9˙e8~f9e8f9f9f9g8zM{Jp;xHzIxHxGwF/vF´uE˙tE˙tD˙sDûrCũqC˙sB ˙xB˙zA˙yB˙xC˙u@˙v?˙v>˙q>˙k> ˙l> ˙l= ˙k= ˙j<˙j<˙i<˙i;˙h;˙h:ũg:úg9˙f9˙e8Ųe8>f8g:f8f9f9f:yHxHxHyHxGvFvFtE;tDŽsCūrC ˙rB ˙qB ūqB ûoAünAūm@˙m@˙m? ˙l? ˙k? ˙l> ˙l= ˙k= ˙k=˙j<˙i<˙i;ūh;üh:ûg:ũg9˙f9˙f8íe8x`3e8f9e8e8f8f9wFwGtCuDwFvFtEtDrC &rB ƒqB ×pA ˙pA ˙o@ ˙o@ ˙n? ˙n? üm? ûl> ûl> ûk= ûk= ûj<ûj<ûi;ûi;ũh:˙g:˙g9˙f9˙f9Öe8td7e8b5e8e8g9e8f3vFuExG|KtEtDrC rB V%pA 6o@ |n@ ģn? čm? ˙l> ˙l> ˙k= ˙k= ˙j<˙j<˙i;˙i;˙h:˙g:˙g:ķf9Ęf9Šf9=~iGe8d7e8e8e8e8e8uE sC}ExDsC rB k;pA o@ o@ m> l> -l> Qk= qk= ˆj<—j<ži;œh;‘h:}g:ag:=f8h:f9f9_-f8e8e8e8e8mIpB pA qA qA pA o@ oA l> l> l> k= k= j<j<i;h;h:g:g:f8j;f9f9e8e8f8e8nA n> r> n? m? m> l> k= k= j<j<i;i;h:g:g:g9f9c8g9f9˙˙€˙˙˙˙ü?˙˙˙đ˙˙˙Ā˙˙˙˙˙ū˙ü˙ø˙đ˙đ˙ā˙ā˙Ā€?€?€€€€ĀĀāāđđøüüū˙˙€˙Ā˙ā˙đ˙ø?˙ū˙˙˙˙˙Ā˙˙˙đ˙˙˙ü?˙˙˙˙Ā˙˙././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/_static/photutils_logo_dark_plain_path.svg0000644000175100001660000021705514755160622024545 0ustar00runnerdocker Photutils logoimage/svg+xmlPhotutils logoLarry Bradley ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/_static/photutils_logo_light_plain_path.svg0000644000175100001660000021705514755160622024733 0ustar00runnerdocker Photutils logoimage/svg+xmlPhotutils logoLarry Bradley ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/changelog.rst0000644000175100001660000000011314755160622016565 0ustar00runnerdocker.. _changelog: ********* Changelog ********* .. include:: ../CHANGES.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/conf.py0000644000175100001660000001532514755160622015416 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Documentation build configuration file. This file is execfile()d with the current directory set to its containing dir. Note that not all possible configuration values are present in this file. All configuration values have a default. Some values are defined in the global Astropy configuration which is loaded here before anything else. See astropy.sphinx.conf for which values are set there. """ import os import sys import tomllib from datetime import UTC, datetime from importlib import metadata from pathlib import Path from sphinx.util import logging logger = logging.getLogger(__name__) try: from sphinx_astropy.conf.v2 import * # noqa: F403 from sphinx_astropy.conf.v2 import extensions # noqa: E402 except ImportError: msg = ('The documentation requires the sphinx-astropy package to be ' 'installed. Please install the "docs" requirements.') logger.error(msg) sys.exit(1) # Get configuration information from pyproject.toml with (Path(__file__).parents[1] / 'pyproject.toml').open('rb') as fh: project_meta = tomllib.load(fh)['project'] # -- Plot configuration ------------------------------------------------------- plot_rcparams = { 'axes.labelsize': 'large', 'figure.figsize': (6, 6), 'figure.subplot.hspace': 0.5, 'savefig.bbox': 'tight', 'savefig.facecolor': 'none', } plot_apply_rcparams = True plot_html_show_source_link = True plot_formats = ['png', 'hires.png', 'pdf', 'svg'] # Don't use the default - which includes a numpy and matplotlib import plot_pre_code = '' # -- General configuration ---------------------------------------------------- # By default, highlight as Python 3. highlight_language = 'python3' # If your documentation needs a minimal Sphinx version, state it here. needs_sphinx = '3.0' # Extend astropy intersphinx_mapping with packages we use here intersphinx_mapping.update( # noqa: F405 {'regions': ('https://astropy-regions.readthedocs.io/en/stable/', None), 'skimage': ('https://scikit-image.org/docs/stable/', None), 'gwcs': ('https://gwcs.readthedocs.io/en/latest/', 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', ] # This is added to the end of RST files - a good place to put # substitutions to be used globally. rst_epilog = """ .. _Astropy: https://www.astropy.org/ """ # -- Project information ------------------------------------------------------ project = project_meta['name'] author = project_meta['authors'][0]['name'] project_copyright = f'2011-{datetime.now(tz=UTC).year}, {author}' github_project = 'astropy/photutils' # The version info for the project you're documenting, acts as # replacement for |version| and |release|, also used in various other # places throughout the built documents. # The full version, including alpha/beta/rc tags. release = metadata.version(project) # The short X.Y version. version = '.'.join(release.split('.')[:2]) dev = 'dev' in release # -- Options for HTML output -------------------------------------------------- html_theme_options.update( # noqa: F405 { 'github_url': 'https://github.com/astropy/photutils', 'header_links_before_dropdown': 6, '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', }, } ) html_title = f'{project} {release}' html_show_sourcelink = False html_favicon = os.path.join('_static', 'photutils_logo.ico') html_static_path = ['_static'] html_css_files = ['custom.css'] # path relative to _static # Output file base name for HTML help builder. htmlhelp_basename = project + 'doc' # Set canonical URL from the Read the Docs Domain html_baseurl = os.environ.get('READTHEDOCS_CANONICAL_URL', '') # A dictionary of values to pass into the template engine's context for # all pages. html_context = { 'default_mode': 'light', 'to_be_indexed': ['stable', 'latest'], 'is_development': dev, 'github_user': 'astropy', 'github_repo': 'photutils', 'github_version': 'main', 'doc_path': 'docs', # Tell Jinja2 templates the build is running on Read the Docs 'READTHEDOCS': os.environ.get('READTHEDOCS', '') == 'True', } # fix size of inheritance diagrams (e.g., PSF diagram was cut off) inheritance_graph_attrs = {'size': '""'} # -- Options for LaTeX output ------------------------------------------------- # Grouping the document tree into LaTeX files. List of tuples (source # start file, target name, title, author, documentclass [howto/manual]). latex_documents = [('index', project + '.tex', project + ' Documentation', author, 'manual')] # latex_logo = '_static/photutils_banner.pdf' # -- Options for manual page output ------------------------------------------- # One entry per manual page. List of tuples (source start file, name, # description, authors, manual section). man_pages = [('index', project.lower(), project + ' Documentation', [author], 1)] # -- Resolving issue number to links in changelog ----------------------------- github_issues_url = f'https://github.com/{github_project}/issues/' # -- Turn on nitpicky mode for sphinx (to warn about references not found) ---- nitpicky = True # Some warnings are impossible to suppress, and you can list specific # references that should be ignored in a nitpick-exceptions file which # should be inside the docs/ directory. The format of the file should be: # # # # for example: # # py:class astropy.io.votable.tree.Element # py:class astropy.io.votable.tree.SimpleElement # py:class astropy.io.votable.tree.SimpleElementWithContent # # Uncomment the following lines to enable the exceptions: nitpick_ignore = [] nitpick_filename = 'nitpick-exceptions.txt' if os.path.isfile(nitpick_filename): with open(nitpick_filename) as fh: for line in fh: if line.strip() == '' or line.startswith('#'): continue dtype, target = line.split(None, 1) target = target.strip() nitpick_ignore.append((dtype, target)) # -- Options for linkcheck output --------------------------------------------- linkcheck_retry = 5 linkcheck_ignore = ['http://data.astropy.org', r'https://iraf.net/*', r'https://github\.com/astropy/photutils/(?:issues|pull)/\d+'] linkcheck_timeout = 180 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6779265 photutils-2.2.0/docs/development/0000755000175100001660000000000014755160634016436 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/development/index.rst0000644000175100001660000000024014755160622020270 0ustar00runnerdocker.. _development: *********** Development *********** .. toctree:: :maxdepth: 1 license.rst ../getting_started/contributing.rst releasing.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/development/license.rst0000644000175100001660000000017514755160622020612 0ustar00runnerdocker.. _photutils_license: License ======= Photutils is licensed under a 3-clause BSD license: .. include:: ../../LICENSE.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/development/releasing.rst0000644000175100001660000001515714755160622021147 0ustar00runnerdocker.. doctest-skip-all **************************** Package Release Instructions **************************** This document outlines the steps for releasing Photutils to `PyPI `_. This process requires admin-level access to the Photutils GitHub repository, as it relies on the ability to push directly to the ``main`` branch. These instructions assume the name of the git remote for the main repository is called ``upstream``. #. Check out the branch that you are going to release. This will usually be the ``main`` branch, unless you are making a release from a bugfix branch. To release from a bugfix branch, check out the ``A.B.x`` branch. Use ``git cherry-pick `` (or ``git cherry-pick -m1 `` for merge commits) to backport fixes to the bugfix branch. Also, be sure to push all changes to the repository so that CI can run on the bugfix branch. #. Ensure that a "What's New" page is added to the documentation for the new release. This page should be added to the ``docs/whats_new`` directory and should be named ``.rst``. Update the "What's New" link on the main page (``docs/index.rst``) to the new version. #. Ensure that `CI tests `_ are passing for the branch you are going to release. Also, ensure that `Read the Docs builds `_ are passing. #. As an extra check, run the tests locally using ``tox`` to thoroughly test the code in isolated environments:: tox -e test-alldeps -- --remote-data tox -e build_docs tox -e linkcheck #. Update the ``CHANGES.rst`` file to make sure that all the changes are listed and update the release date from ``unreleased`` to the current date in ``yyyy-mm-dd`` format. Then commit the changes:: git add CHANGES.rst git commit -m'Finalizing changelog for version ' #. Create an annotated git tag (optionally signing with the ``-s`` option) for the version number you are about to release:: git tag -a -m'' git show # show the tag git tag # show all tags #. Optionally, :ref:`even more manual tests ` can be run. .. _resume_release: #. Push this new tag to the upstream repo:: git push upstream The new tag will trigger the automated `Publish workflow `_ to build the source distribution and wheels and upload them to `PyPI `_. #. Create a `GitHub Release `_ by clicking on "Draft a new release", select the tag of the released version, add a release title with the released version, and add the following description:: See the [changelog](https://photutils.readthedocs.io/en/stable/changelog.html) for release notes. Then click "Publish release". This step will trigger an automatic update of the package on Zenodo (see below). #. Check that `Zenodo `_ is updated with the released version. Zenodo is already configured to automatically update with a new published GitHub Release (see above). #. Open a new `GitHub Milestone `_ for the next release. If there are any open issues or pull requests for the new released version, then move them to the next milestone. After there are no remaining open issues or pull requests for the released version then close its GitHub Milestone. #. Go to `Read the Docs `_ and check that the "stable" docs correspond to the new released version. Hide any older released versions (i.e., check "Hidden"). #. Update ``CHANGES.rst``, adding new sections for the next ``x.y.z`` version, e.g.,:: x.y.z (unreleased) ------------------ General ^^^^^^^ New Features ^^^^^^^^^^^^ Bug Fixes ^^^^^^^^^ API Changes ^^^^^^^^^^^ Then commit the changes and push to the upstream repo:: git add CHANGES.rst git commit -m'Add version to the changelog' git push upstream main #. After the release, the conda-forge bot (``regro-cf-autotick-bot``) will automatically create a pull request to the `Photutils feedstock repository `_. The ``meta.yaml`` recipe may need to be edited to update dependencies or versions. Modify (if necessary), review, and merge the PR to create the `conda-forge package `_. The `Astropy conda channel `_ will automatically mirror the package from conda-forge. .. _manual_tests: Additional Manual Tests ----------------------- These additional manual checks can be run before pushing the release tag to the upstream repository. #. Remove any untracked files (**WARNING: this will permanently remove any files that have not been previously committed**, so make sure that you don't need to keep any of these files):: git clean -dfx #. Check out the release tag:: git checkout #. Ensure the `build `_ and `twine `_ packages are installed and up to date:: pip install build twine --upgrade #. Generate the source distribution tar file:: python -m build --sdist . and perform a preliminary check of the tar file:: python -m twine check --strict dist/* #. Run tests on the generated source distribution by going inside the ``dist`` directory, expanding the tar file, going inside the expanded directory, and running the tests with:: cd dist tar xvfz .tar.gz cd tox -e test-alldeps -- --remote-data tox -e build_docs Optionally, install and test the source distribution in a virtual environment:: pip install -e '.[all,test]' pytest --remote-data or:: pip install '../.tar.gz[all,test]' cd pytest --pyargs photutils --remote-data #. Check out the ``main`` branch, go back to the package root directory, and remove the generated files with:: git checkout main cd ../.. git clean -dfx #. Go back to the :ref:`release steps ` where you left off. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6789265 photutils-2.2.0/docs/getting_started/0000755000175100001660000000000014755160634017303 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/getting_started/citation.rst0000644000175100001660000000007114755160622021642 0ustar00runnerdocker.. _citation: .. include:: ../../photutils/CITATION.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/getting_started/contributing.rst0000644000175100001660000000234314755160622022543 0ustar00runnerdockerReporting Issues and Contributing ================================= Reporting Issues ---------------- If you have found a bug, please report it by creating a new issue on the `Photutils GitHub issue tracker `_. That requires creating a `free GitHub account `_ if you do not have one. Please include a minimal example that demonstrates the issue and will allow the developers to reproduce and fix the problem. You may be also asked to provide information about your operating system and a full Python stack trace. The developers will walk you through obtaining a stack trace if it is necessary. Contributing ------------ Like the `Astropy`_ project, this package is made both by and for its users. We accept contributions at all levels, spanning the gamut from fixing a typo in the documentation to developing a major new feature. We welcome contributors who will abide by the `Python Software Foundation Code of Conduct `_. This package follows the same workflow and coding guidelines as `Astropy`_. Please read the `Astropy Development documentation `_ to get started. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/getting_started/contributors.rst0000644000175100001660000000026514755160622022572 0ustar00runnerdockerContributors ============ For the complete list of contributors please see the `Photutils contributors page on GitHub `_. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/getting_started/importing.rst0000644000175100001660000000317214755160622022045 0ustar00runnerdocker.. doctest-skip-all .. _importing: Importing from Photutils ======================== Photutils is organized into subpackages covering different topics. Importing only ``photutils`` will not import the tools in the subpackages. There are no tools available in the top-level ``photutils`` namespace. For example, the following will not work:: >>> import photutils >>> aper = photutils.CircularAperture((10, 20), r=4) The tools in each subpackage must be imported separately. For example, to import the aperture photometry tools, use:: >>> from photutils import aperture >>> aper = aperture.CircularAperture((10, 20), r=4) or:: >>> from photutils.aperture import CircularAperture >>> aper = CircularAperture((10, 20), r=4) .. warning:: *Do not import from specific modules of packages.* This is unnecessary and the internal organization of the package may change without notice. All public tools are available in the package top-level namespace. For example, do not import from the ``circle`` module within the ``aperture`` package:: >>> from photutils.aperture.circle import CircularAperture >>> aper = CircularAperture((10, 20), r=4) .. warning:: Modules, functions, classes, methods, and attributes whose names begin with a leading underscore are considered private objects and should not be imported or accessed. If a module name in a package begins with a leading underscore, then none of its members are public, regardless of whether they begin with a leading underscore. Private objects are not intended for public use and may change without notice. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/getting_started/index.rst0000644000175100001660000000035114755160622021140 0ustar00runnerdocker.. _getting_started: *************** Getting Started *************** .. toctree:: :maxdepth: 1 install overview importing pixel_conventions citation contributing contributors ../whats_new/index ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/getting_started/install.rst0000644000175100001660000001213214755160622021477 0ustar00runnerdocker************ Installation ************ Requirements ============ Photutils has the following strict requirements: * `Python `_ 3.11 or later * `NumPy `_ 1.24 or later * `Astropy`_ 5.3 or later * `SciPy `_ 1.10 or later Photutils also optionally depends on other packages for some features: * `Matplotlib `_ 3.7 or later: Used to power a variety of plotting features (e.g., plotting apertures). * `Regions `_ 0.9 or later: Required to perform aperture photometry using region objects. * `scikit-image `_ 0.20 or later: Required to deblend segmented sources. * `GWCS `_ 0.20 or later: Required in `~photutils.datasets.make_gwcs` to create a simple celestial gwcs object. * `Bottleneck `_: Improves the performance of sigma clipping and other functionality that may require computing statistics on arrays with NaN values. * `tqdm `_: Required to display optional progress bars. * `Rasterio `_: Required to convert source segments into polygon objects. * `Shapely `_: Required to convert source segments into polygon objects. Installing the latest released version ====================================== Using pip --------- To install Photutils with `pip`_, run:: pip install photutils If you want to install Photutils along with all of its optional dependencies, you can instead run:: pip install "photutils[all]" In most cases, this will install a pre-compiled version (called a wheel) of Photutils, but if you are using a very recent version of Python or if you are installing Photutils on a platform that is not common, Photutils will be installed from a source file. In this case you will need a C compiler (e.g., ``gcc`` or ``clang``) to be installed for the installation to succeed (see :ref:`building_source` prerequisites). If you get a ``PermissionError``, this means that you do not have the required administrative access to install new packages to your Python installation. In this case you may consider using the ``--user`` option to install the package into your home directory. You can read more about how to do this in the `pip documentation `_. Using conda ----------- Photutils can also be installed using the ``conda`` package manager. There are several methods for installing ``conda`` and many different ways to set up your Python environment, but that is beyond the scope of this documentation. We recommend installing `miniforge `__. Once you have installed ``conda``, you can install Photutils using the ``conda-forge`` channel:: conda install -c conda-forge photutils .. _building_source: Building from Source ==================== Prerequisites ------------- You will need a compiler suite and the development headers for Python and Numpy in order to build Photutils from the source distribution. You do not need to install any other specific build dependencies (such as Cython) since these will be automatically installed into a temporary build environment by `pip`_. On Linux, using the package manager for your distribution will usually be the easiest route. On macOS you will need the `XCode`_ command-line tools, which can be installed using:: xcode-select --install Follow the onscreen instructions to install the command-line tools required. Note that you do not need to install the full `XCode`_ distribution (assuming you are using MacOS X 10.9 or later). Installing the development version ---------------------------------- Photutils is being developed on `GitHub`_. The latest development version of the Photutils source code can be retrieved using git:: git clone https://github.com/astropy/photutils.git Then to build and install Photutils (with all of its optional dependencies), run:: cd photutils pip install ".[all]" If you wish to install the package in "editable" mode, instead include the "-e" option:: pip install -e ".[all]" Alternatively, `pip`_ can be used to retrieve and install the latest development pre-built wheel:: pip install --upgrade --extra-index-url https://pypi.anaconda.org/astropy/simple "photutils[all]" --pre Testing an installed Photutils ============================== To test your installed version of Photutils, you can run the test suite using the `pytest`_ command. Running the test suite requires installing the `pytest-astropy `_ (0.11 or later) package. To run the test suite, use the following command:: pytest --pyargs photutils Any test failures can be reported to the `Photutils issue tracker `_. .. _pip: https://pip.pypa.io/en/latest/ .. _GitHub: https://github.com/astropy/photutils .. _Xcode: https://developer.apple.com/xcode/ .. _pytest: https://docs.pytest.org/en/latest/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/getting_started/overview.rst0000644000175100001660000000230414755160622021677 0ustar00runnerdockerOverview ======== Introduction ------------ Photutils contains tools for: * performing aperture photometry * performing PSF-fitting photometry * detecting and extracting point-like sources (e.g., stars) in astronomical images * detecting and extracting extended sources using image segmentation in astronomical images * estimating the background and background RMS in astronomical images * centroiding sources * creating radial profiles and curves of growth * building an effective Point Spread Function (ePSF) * matching PSF kernels * estimating morphological parameters of detected sources * estimating the limiting depths of images * fitting elliptical isophotes to galaxies * creating simulated astronomical images The code and issue tracker are available at the following links: * Code: https://github.com/astropy/photutils * Issue Tracker: https://github.com/astropy/photutils/issues Like much astronomy software, Photutils is an evolving package. The developers try to maintain backwards compatibility, but at times the API may change if there is a benefit to doing so. If there are specific areas you think API stability is important, please let us know as part of the development process. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/getting_started/pixel_conventions.rst0000644000175100001660000000217314755160622023603 0ustar00runnerdockerPixel Coordinate Conventions ============================ In Photutils, integer pixel coordinates are located at the center of pixels, and they are 0-indexed, matching the Python 0-based indexing. That means the first pixel is considered pixel ``0``, but pixel coordinate ``0`` is the *center* of that pixel. Hence, the first pixel spans pixel values ``-0.5`` to ``0.5``. For a 2-dimensional array, ``(x, y) = (0, 0)`` corresponds to the *center* of the bottom, leftmost array element. That means the first pixel spans the ``x`` and ``y`` pixel values from ``-0.5`` to ``0.5``. Note that this differs from the IRAF, `FITS WCS `_, `ds9`_, and `SourceExtractor`_ conventions, in which the center of the bottom, leftmost array element is ``(x, y) = (1, 1)``. Following Python indexing, two-dimensional arrays are indexed as ``image[yi, xi]``, with 0 being the first index. The ``xi`` (column) index corresponds to the second (fast) array index and the ``yi`` (row) index corresponds to the first (slow) index. .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ .. _ds9: http://ds9.si.edu/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/index.rst0000644000175100001660000000542514755160622015760 0ustar00runnerdocker :tocdepth: 3 .. |br| raw:: html
.. image:: _static/photutils_logo_light_plain_path.svg :class: only-light :width: 55% :align: center :alt: Photutils logo .. image:: _static/photutils_logo_dark_plain_path.svg :class: only-dark :width: 55% :align: center :alt: Photutils logo ********* Photutils ********* | **Version**: |release| -- **Date**: |today| | Useful links: :doc:`getting_started/install` | :ref:`whatsnew-2.2` **Photutils** is a Python library that provides commonly-used tools and key functionality for detecting and performing photometry of astronomical sources. Tools are provided for background estimation, star finding, source detection and extraction, aperture photometry, PSF photometry, image segmentation, centroids, radial profiles, and elliptical isophote fitting. It is a `coordinated package `_ of `Astropy`_ and integrates well with other Astropy packages, making it a powerful tool for astronomical image analysis. .. admonition:: Important If you use Photutils for a project that leads to a publication, whether directly or as a dependency of another package, please include an :ref:`acknowledgment and/or citation `. |br| .. toctree:: :maxdepth: 1 :hidden: getting_started/index user_guide/index reference/index development/index Release Notes .. grid:: 3 :gutter: 2 3 4 4 .. grid-item-card:: :text-align: center **Getting Started** ^^^^^^^^^^^^^^^^^^^ New to Photutils? Check out the getting started guides. They contain an overview of Photutils and an introduction to its main concepts. +++ .. button-ref:: getting_started/index :expand: :color: primary :click-parent: To the getting started guides .. grid-item-card:: :text-align: center **User Guide** ^^^^^^^^^^^^^^ The user guide provides in-depth information on the key concepts of Photutils with useful background information and explanation. +++ .. button-ref:: user_guide/index :expand: :color: primary :click-parent: To the user guide .. grid-item-card:: :text-align: center **API Reference** ^^^^^^^^^^^^^^^^^ The reference guide contains a detailed description of the functions, modules, and objects included in Photutils. It assumes that you have an understanding of the key concepts. +++ .. button-ref:: reference/index :expand: :color: primary :click-parent: To the reference guide ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/make.bat0000644000175100001660000001070514755160622015521 0ustar00runnerdocker@ECHO OFF REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set BUILDDIR=_build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. singlehtml to make a single large HTML file echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. qthelp to make HTML files and a qthelp project echo. devhelp to make HTML files and a Devhelp project echo. epub to make an epub echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. text to make text files echo. man to make manual pages echo. changes to make an overview over all changed/added/deprecated items echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled goto end ) if "%1" == "clean" ( for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i del /q /s %BUILDDIR%\* del /q /s api del /q /s generated goto end ) if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) if "%1" == "singlehtml" ( %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. goto end ) if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Astropy.qhcp echo.To view the help file: echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Astropy.ghc goto end ) if "%1" == "devhelp" ( %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp if errorlevel 1 exit /b 1 echo. echo.Build finished. goto end ) if "%1" == "epub" ( %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub file is in %BUILDDIR%/epub. goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex if errorlevel 1 exit /b 1 echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) if "%1" == "text" ( %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text if errorlevel 1 exit /b 1 echo. echo.Build finished. The text files are in %BUILDDIR%/text. goto end ) if "%1" == "man" ( %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man if errorlevel 1 exit /b 1 echo. echo.Build finished. The manual pages are in %BUILDDIR%/man. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes if errorlevel 1 exit /b 1 echo. echo.The overview file is in %BUILDDIR%/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck if errorlevel 1 exit /b 1 echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest if errorlevel 1 exit /b 1 echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. goto end ) :end ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/nitpick-exceptions.txt0000644000175100001660000000036214755160622020473 0ustar00runnerdocker# these are needed if photutils.psf automodapi uses ":inherited-members:" py:class CompoundModel py:class Model py:obj CompoundModel py:obj Model.evaluate py:obj Model.inputs py:obj Model.outputs py:obj Model.bounding_box py:obj custom_model ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6819265 photutils-2.2.0/docs/reference/0000755000175100001660000000000014755160634016052 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/reference/aperture_api.rst0000644000175100001660000000033614755160622021263 0ustar00runnerdocker =============================================== Aperture Photometry (:mod:`photutils.aperture`) =============================================== .. automodapi:: photutils.aperture :no-heading: :inherited-members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/reference/background_api.rst0000644000175100001660000000031614755160622021551 0ustar00runnerdocker ========================================= Backgrounds (:mod:`photutils.background`) ========================================= .. automodapi:: photutils.background :no-heading: :inherited-members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/reference/centroids_api.rst0000644000175100001660000000030414755160622021421 0ustar00runnerdocker ====================================== Centroids (:mod:`photutils.centroids`) ====================================== .. automodapi:: photutils.centroids :no-heading: :inherited-members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/reference/datasets_api.rst0000644000175100001660000000035214755160622021242 0ustar00runnerdocker =================================================== Datasets and Simulation (:mod:`photutils.datasets`) =================================================== .. automodapi:: photutils.datasets :no-heading: :inherited-members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/reference/detection_api.rst0000644000175100001660000000037214755160622021412 0ustar00runnerdocker ======================================================== Point-like Source Detection (:mod:`photutils.detection`) ======================================================== .. automodapi:: photutils.detection :no-heading: :inherited-members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/reference/geometry_api.rst0000644000175100001660000000033314755160622021264 0ustar00runnerdocker ============================================== Geometry Functions (:mod:`photutils.geometry`) ============================================== .. automodapi:: photutils.geometry :no-heading: :inherited-members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/reference/index.rst0000644000175100001660000000232314755160622017710 0ustar00runnerdocker.. _api_reference: ************* API Reference ************* The API reference guide contains detailed descriptions of the functions, modules, and objects included in each of the Photutils subpackages. It assumes that you have an understanding of the key concepts presented in the :ref:`user_guide`. Please see :ref:`importing` for information on how to import these tools. * :doc:`photutils.aperture ` * :doc:`photutils.background ` * :doc:`photutils.centroids ` * :doc:`photutils.datasets ` * :doc:`photutils.detection ` * :doc:`photutils.geometry ` * :doc:`photutils.isophote ` * :doc:`photutils.morphology ` * :doc:`photutils.profiles ` * :doc:`photutils.psf ` - :doc:`photutils.psf.matching ` * :doc:`photutils.segmentation ` * :doc:`photutils.utils ` .. toctree:: :maxdepth: 1 :hidden: aperture_api background_api centroids_api datasets_api detection_api geometry_api isophote_api morphology_api profiles_api psf_api psf_matching_api segmentation_api utils_api ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/reference/isophote_api.rst0000644000175100001660000000037114755160622021265 0ustar00runnerdocker ======================================================== Elliptical Isophote Analysis (:mod:`photutils.isophote`) ======================================================== .. automodapi:: photutils.isophote :no-heading: :inherited-members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/reference/morphology_api.rst0000644000175100001660000000036514755160622021635 0ustar00runnerdocker ====================================================== Morphological Properties (:mod:`photutils.morphology`) ====================================================== .. automodapi:: photutils.morphology :no-heading: :inherited-members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/reference/profiles_api.rst0000644000175100001660000000032214755160622021252 0ustar00runnerdocker =========================================== Radial Profiles (:mod:`photutils.profiles`) =========================================== .. automodapi:: photutils.profiles :no-heading: :inherited-members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/reference/psf_api.rst0000644000175100001660000000027314755160622020224 0ustar00runnerdocker ===================================== PSF Photometry (:mod:`photutils.psf`) ===================================== .. automodapi:: photutils.psf :no-heading: :inherited-members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/reference/psf_matching_api.rst0000644000175100001660000000033114755160622022071 0ustar00runnerdocker ============================================ PSF Matching (:mod:`photutils.psf.matching`) ============================================ .. automodapi:: photutils.psf.matching :no-heading: :inherited-members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/reference/segmentation_api.rst0000644000175100001660000000035314755160622022130 0ustar00runnerdocker ================================================== Image Segmentation (:mod:`photutils.segmentation`) ================================================== .. automodapi:: photutils.segmentation :no-heading: :inherited-members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/reference/utils_api.rst0000644000175100001660000000031414755160622020570 0ustar00runnerdocker ========================================== Utility Functions (:mod:`photutils.utils`) ========================================== .. automodapi:: photutils.utils :no-heading: :inherited-members: ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6849265 photutils-2.2.0/docs/user_guide/0000755000175100001660000000000014755160634016247 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/user_guide/aperture.rst0000644000175100001660000011060414755160622020627 0ustar00runnerdocker.. _photutils-aperture: Aperture Photometry (`photutils.aperture`) ========================================== Introduction ------------ The :func:`~photutils.aperture.aperture_photometry` function and the :class:`~photutils.aperture.ApertureStats` class are the main tools to perform aperture photometry on an astronomical image for a given set of apertures. .. _photutils-apertures: Apertures --------- Photutils provides several apertures defined in pixel or sky coordinates. The aperture classes that are defined in pixel coordinates are: * `~photutils.aperture.CircularAperture` * `~photutils.aperture.CircularAnnulus` * `~photutils.aperture.EllipticalAperture` * `~photutils.aperture.EllipticalAnnulus` * `~photutils.aperture.RectangularAperture` * `~photutils.aperture.RectangularAnnulus` Each of these classes has a corresponding variant defined in sky coordinates: * `~photutils.aperture.SkyCircularAperture` * `~photutils.aperture.SkyCircularAnnulus` * `~photutils.aperture.SkyEllipticalAperture` * `~photutils.aperture.SkyEllipticalAnnulus` * `~photutils.aperture.SkyRectangularAperture` * `~photutils.aperture.SkyRectangularAnnulus` To perform aperture photometry with sky-based apertures, one will need to specify a WCS transformation. The :func:`~photutils.aperture.aperture_photometry` function and the :class:`~photutils.aperture.ApertureStats` class both accept `~photutils.aperture.Aperture` objects. They can also accept a supported `regions.Region` object, i.e. a region that corresponds to the above aperture classes, as input. The :func:`~photutils.aperture.aperture_photometry` function also accepts a list of `~photutils.aperture.Aperture` or `regions.Region` objects if each aperture/region has identical positions. The :func:`~photutils.aperture.region_to_aperture` convenience function can also be used to convert a `regions.Region` object to a `~photutils.aperture.Aperture` object. Users can also create their own custom apertures (see :ref:`custom-apertures`). .. _creating-aperture-objects: Creating Aperture Objects ------------------------- The first step in performing aperture photometry is to create an aperture object. An aperture object is defined by a position (or a list of positions) and parameters that define its size and possibly, orientation (e.g., an elliptical aperture). We start with an example of creating a circular aperture in pixel coordinates using the :class:`~photutils.aperture.CircularAperture` class:: >>> from photutils.aperture import CircularAperture >>> positions = [(30.0, 30.0), (40.0, 40.0)] >>> aperture = CircularAperture(positions, r=3.0) The positions should be either a single tuple of ``(x, y)``, a list of ``(x, y)`` tuples, or an array with shape ``Nx2``, where ``N`` is the number of positions. The above example defines two circular apertures located at pixel coordinates ``(30, 30)`` and ``(40, 40)`` with a radius of 3 pixels. Creating an aperture object in sky coordinates is similar. One first uses the :class:`~astropy.coordinates.SkyCoord` class to define sky coordinates and then the :class:`~photutils.aperture.SkyCircularAperture` class to define the aperture object:: >>> from astropy import units as u >>> from astropy.coordinates import SkyCoord >>> from photutils.aperture import SkyCircularAperture >>> positions = SkyCoord(l=[1.2, 2.3] * u.deg, b=[0.1, 0.2] * u.deg, ... frame='galactic') >>> aperture = SkyCircularAperture(positions, r=4.0 * u.arcsec) .. note:: Sky apertures are not defined completely in sky coordinates. They simply use sky coordinates to define the central position, and the remaining parameters are converted to pixels using the pixel scale of the image at the central position. Projection distortions are not taken into account. They are **not** defined as apertures on the celestial sphere, but rather are meant to represent aperture shapes on an image. If the apertures were defined completely in sky coordinates, their shapes would not be preserved when converting to or from pixel coordinates. Converting Between Pixel and Sky Apertures ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The pixel apertures can be converted to sky apertures, and vice versa, given a WCS object. To accomplish this, use the :meth:`~photutils.aperture.PixelAperture.to_sky` method for pixel apertures. For this example, we'll use a sample WCS object:: >>> from photutils.datasets import make_wcs >>> wcs = make_wcs((100, 100)) >>> aperture = CircularAperture((10, 20), r=4.0) >>> sky_aperture = aperture.to_sky(wcs) >>> sky_aperture # doctest: +FLOAT_CMP , r=0.39999999985539925 arcsec)> and the :meth:`~photutils.aperture.SkyAperture.to_pixel` method for sky apertures, e.g.,:: >>> position = SkyCoord(197.893, -1.366, unit='deg', frame='icrs') >>> aperture = SkyCircularAperture(position, r=0.4 * u.arcsec) >>> pix_aperture = aperture.to_pixel(wcs) >>> pix_aperture # doctest: +FLOAT_CMP Performing Aperture Photometry ------------------------------ After the aperture object is created, we can then perform the photometry using the :func:`~photutils.aperture.aperture_photometry` function. We start by defining the aperture (at two positions) as described above:: >>> positions = [(30.0, 30.0), (40.0, 40.0)] >>> aperture = CircularAperture(positions, r=3.0) We then call the :func:`~photutils.aperture.aperture_photometry` function with the data and the apertures. Note that :func:`~photutils.aperture.aperture_photometry` assumes that the input data have been background subtracted. For simplicity, we define the data here as an array of all ones:: >>> import numpy as np >>> from photutils.aperture import aperture_photometry >>> data = np.ones((100, 100)) >>> phot_table = aperture_photometry(data, aperture) >>> phot_table['aperture_sum'].info.format = '%.8g' # for consistent table output >>> print(phot_table) id xcenter ycenter aperture_sum --- ------- ------- ------------ 1 30.0 30.0 28.274334 2 40.0 40.0 28.274334 This function returns the results of the photometry in an Astropy `~astropy.table.QTable`. In this example, the table has four columns, named ``'id'``, ``'xcenter'``, ``'ycenter'``, and ``'aperture_sum'``. Since all the data values are 1.0, the aperture sums are equal to the area of a circle with a radius of 3:: >>> print(np.pi * 3.0 ** 2) # doctest: +FLOAT_CMP 28.2743338823 .. _photutils-aperture-overlap: Aperture and Pixel Overlap -------------------------- The overlap of the aperture with the data pixels can be handled in different ways. The default method (``method='exact'``) calculates the exact intersection of the aperture with each pixel. The other options, ``'center'`` and ``'subpixel'``, are faster, but with the expense of less precision. With ``'center'``, a pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. With ``'subpixel'``, pixels are divided into a number of subpixels, which are in or out of the aperture based on their centers. For this method, the number of subpixels needs to be set with the ``subpixels`` keyword. This example uses the ``'subpixel'`` method where pixels are resampled by a factor of 5 (``subpixels=5``) in each dimension:: >>> phot_table = aperture_photometry(data, aperture, method='subpixel', ... subpixels=5) >>> print(phot_table) # doctest: +SKIP id xcenter ycenter aperture_sum --- ------- ------- ------------ 1 30.0 30.0 27.96 2 40.0 40.0 27.96 Note that the results differ from the exact value of 28.274333 (see above). For the ``'subpixel'`` method, the default value is ``subpixels=5``, meaning that each pixel is equally divided into 25 smaller pixels (this is the method and subsampling factor used in `SourceExtractor `_). The precision can be increased by increasing ``subpixels``, but note that computation time will be increased. Aperture Photometry with Multiple Apertures at Each Position ------------------------------------------------------------ While the `~photutils.aperture.Aperture` objects support multiple positions, they must have a fixed size and shape (e.g., radius and orientation). To perform photometry in multiple apertures at each position, one may input a list of aperture objects to the :func:`~photutils.aperture.aperture_photometry` function. In this case, the apertures must all have identical position(s). Suppose that we wish to use three circular apertures, with radii of 3, 4, and 5 pixels, on each source:: >>> radii = [3.0, 4.0, 5.0] >>> apertures = [CircularAperture(positions, r=r) for r in radii] >>> phot_table = aperture_photometry(data, apertures) >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> print(phot_table) id xcenter ycenter aperture_sum_0 aperture_sum_1 aperture_sum_2 --- ------- ------- -------------- -------------- -------------- 1 30 30 28.274334 50.265482 78.539816 2 40 40 28.274334 50.265482 78.539816 For multiple apertures, the output table column names are appended with the ``positions`` index. Other apertures have multiple parameters specifying the aperture size and orientation. For example, for elliptical apertures, one must specify ``a``, ``b``, and ``theta``:: >>> from astropy.coordinates import Angle >>> from photutils.aperture import EllipticalAperture >>> a = 5.0 >>> b = 3.0 >>> theta = Angle(45, 'deg') >>> apertures = EllipticalAperture(positions, a, b, theta) >>> phot_table = aperture_photometry(data, apertures) >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> print(phot_table) id xcenter ycenter aperture_sum --- ------- ------- ------------ 1 30 30 47.12389 2 40 40 47.12389 Again, for multiple apertures one should input a list of aperture objects, each with identical positions:: >>> a = [5.0, 6.0, 7.0] >>> b = [3.0, 4.0, 5.0] >>> theta = Angle(45, 'deg') >>> apertures = [EllipticalAperture(positions, a=ai, b=bi, theta=theta) ... for (ai, bi) in zip(a, b)] >>> phot_table = aperture_photometry(data, apertures) >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> print(phot_table) id xcenter ycenter aperture_sum_0 aperture_sum_1 aperture_sum_2 --- ------- ------- -------------- -------------- -------------- 1 30 30 47.12389 75.398224 109.95574 2 40 40 47.12389 75.398224 109.95574 .. _photutils-aperture-stats: Aperture Statistics ------------------- The :class:`~photutils.aperture.ApertureStats` class can be used to create a catalog of statistics and properties for pixels within an aperture, including aperture photometry. It can calculate many properties, including statistics like :attr:`~photutils.aperture.ApertureStats.min`, :attr:`~photutils.aperture.ApertureStats.max`, :attr:`~photutils.aperture.ApertureStats.mean`, :attr:`~photutils.aperture.ApertureStats.median`, :attr:`~photutils.aperture.ApertureStats.std`, :attr:`~photutils.aperture.ApertureStats.sum_aper_area`, and :attr:`~photutils.aperture.ApertureStats.sum`. It also can be used to calculate morphological properties like :attr:`~photutils.aperture.ApertureStats.centroid`, :attr:`~photutils.aperture.ApertureStats.fwhm`, :attr:`~photutils.aperture.ApertureStats.semimajor_sigma`, :attr:`~photutils.aperture.ApertureStats.semiminor_sigma`, :attr:`~photutils.aperture.ApertureStats.orientation`, and :attr:`~photutils.aperture.ApertureStats.eccentricity`. Please see :class:`~photutils.aperture.ApertureStats` for the complete list of properties that can be calculated. The properties can be accessed using `~photutils.aperture.ApertureStats` attributes or output to an Astropy `~astropy.table.QTable` using the :meth:`~photutils.aperture.ApertureStats.to_table` method. Most of the source properties are calculated using the "center" :ref:`aperture-mask method `, which gives aperture weights of 0 or 1. This avoids the need to compute weighted statistics --- the ``data`` pixel values are directly used. The ``sum_method`` and ``subpixels`` keywords are used to determine the aperture-mask method when calculating the sum-related properties: ``sum``, ``sum_error``, ``sum_aper_area``, ``data_sumcutout``, and ``error_sumcutout``. The default is ``sum_method='exact'``, which produces exact aperture-weighted photometry. The optional ``local_bkg`` keyword can be used to input the per-pixel local background of each source, which will be subtracted before computing the aperture statistics. The optional ``sigma_clip`` keyword can be used to sigma clip the pixel values before computing the source properties. This keyword could be used, for example, to compute a sigma-clipped median of pixels in an annulus aperture to estimate the local background level. Here is a simple example using a circular aperture at one position. Note that like :func:`~photutils.aperture.aperture_photometry`, :class:`~photutils.aperture.ApertureStats` expects the input data to be background subtracted. For simplicity, here we roughly estimate the background as the sigma-clipped median value:: >>> from astropy.stats import sigma_clipped_stats >>> from photutils.aperture import ApertureStats, CircularAperture >>> from photutils.datasets import make_4gaussians_image >>> data = make_4gaussians_image() >>> _, median, _ = sigma_clipped_stats(data, sigma=3.0) >>> data -= median # subtract background from the data >>> aper = CircularAperture((150, 25), 8) >>> aperstats = ApertureStats(data, aper) # doctest: +FLOAT_CMP >>> print(aperstats.xcentroid) # doctest: +FLOAT_CMP 149.98963482915323 >>> print(aperstats.ycentroid) # doctest: +FLOAT_CMP 24.97165265459083 >>> print(aperstats.centroid) # doctest: +FLOAT_CMP [149.98963483 24.97165265] >>> print(aperstats.mean, aperstats.median, aperstats.std) # doctest: +FLOAT_CMP 42.38192194155781 26.53270189818481 39.19365538349298 >>> print(aperstats.sum) # doctest: +FLOAT_CMP 8204.777345704442 Similar to `~photutils.aperture.aperture_photometry`, the input aperture can have multiple positions:: >>> aper2 = CircularAperture(((150, 25), (90, 60)), 10) >>> aperstats2 = ApertureStats(data, aper2) >>> print(aperstats2.xcentroid) # doctest: +FLOAT_CMP [149.98175939 89.97793821] >>> print(aperstats2.sum) # doctest: +FLOAT_CMP [ 8487.10695247 34963.45850824] >>> columns = ('id', 'mean', 'median', 'std', 'var', 'sum') >>> stats_table = aperstats2.to_table(columns) >>> for col in stats_table.colnames: ... stats_table[col].info.format = '%.8g' # for consistent table output >>> print(stats_table) # doctest: +FLOAT_CMP id mean median std var sum --- --------- --------- --------- --------- --------- 1 27.915818 12.582676 36.628464 1341.6444 8487.107 2 113.18737 112.11505 49.756626 2475.7218 34963.459 Each row of the table corresponds to a single aperture position (i.e., a single source). Background Subtraction ---------------------- Global Background Subtraction ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :func:`~photutils.aperture.aperture_photometry` and :class:`~photutils.aperture.ApertureStats` assume that the input data have been background-subtracted. If ``bkg`` is a float value or an array representing the background of the data (e.g., determined by `~photutils.background.Background2D` or an external function), simply subtract the background from the data:: >>> phot_table = aperture_photometry(data - bkg, aperture) # doctest: +SKIP In the case of a constant global background, you can pass in the background value using ``local_bkg`` in :class:`~photutils.aperture.ApertureStats`. This would avoid reading an entire memory-mapped array into memory beforehand, as would happen if you manually subtract the background as shown above. So instead you could do this:: >>> aperstats = ApertureStats(data, aperture, local_bkg=bkg) # doctest: +SKIP Local Background Subtraction ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ One often wants to also estimate the local background around each source using a nearby aperture or annulus aperture surrounding each source. A simple method for doing this is to use the :class:`~photutils.aperture.ApertureStats` class (see :ref:`photutils-aperture-stats`) to compute the mean background level within the background aperture. This class can also be used to calculate more advanced statistics (e.g., a sigma-clipped median) within the background aperture (e.g., a circular annulus). We show examples of both below. Let's start by generating a more realistic example dataset:: >>> from photutils.datasets import make_100gaussians_image >>> data = make_100gaussians_image() This artificial image has a known constant background level of 5. In the following examples, we'll leave this global background in the image to be estimated using local backgrounds. For this example we perform the photometry for three sources in a circular aperture with a radius of 5 pixels. The local background level around each source is estimated using a circular annulus of inner radius 10 pixels and outer radius 15 pixels. Let's define the apertures:: >>> from photutils.aperture import CircularAnnulus, CircularAperture >>> positions = [(145.1, 168.3), (84.5, 224.1), (48.3, 200.3)] >>> aperture = CircularAperture(positions, r=5) >>> annulus_aperture = CircularAnnulus(positions, r_in=10, r_out=15) Now let's plot the circular apertures (white) and circular annulus apertures (red) on a cutout from the image containing the three sources: .. plot:: import matplotlib.pyplot as plt from astropy.visualization import simple_norm from photutils.aperture import CircularAnnulus, CircularAperture from photutils.datasets import make_100gaussians_image data = make_100gaussians_image() positions = [(145.1, 168.3), (84.5, 224.1), (48.3, 200.3)] aperture = CircularAperture(positions, r=5) annulus_aperture = CircularAnnulus(positions, r_in=10, r_out=15) norm = simple_norm(data, 'sqrt', percent=99) plt.imshow(data, norm=norm, interpolation='nearest') plt.xlim(0, 170) plt.ylim(130, 250) ap_patches = aperture.plot(color='white', lw=2, label='Photometry aperture') ann_patches = annulus_aperture.plot(color='red', lw=2, label='Background annulus') handles = (ap_patches[0], ann_patches[0]) plt.legend(loc=(0.17, 0.05), facecolor='#458989', labelcolor='white', handles=handles, prop={'weight': 'bold', 'size': 11}) Simple mean within a circular annulus """"""""""""""""""""""""""""""""""""" We can use the :class:`~photutils.aperture.ApertureStats` class to compute the mean background level within the annulus aperture at each position:: >>> from photutils.aperture import ApertureStats >>> aperstats = ApertureStats(data, annulus_aperture) >>> bkg_mean = aperstats.mean >>> print(bkg_mean) # doctest: +FLOAT_CMP [4.99411764 5.1349344 4.86894665] Now let's use :func:`~photutils.aperture.aperture_photometry` to perform the photometry in the circular aperture (in the next example, we'll use :class:`~photutils.aperture.ApertureStats` to perform the photometry):: >>> from photutils.aperture import aperture_photometry >>> phot_table = aperture_photometry(data, aperture) >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> print(phot_table) id xcenter ycenter aperture_sum --- ------- ------- ------------ 1 145.1 168.3 1128.1245 2 84.5 224.1 735.739 3 48.3 200.3 1299.6341 The total background within the circular aperture is the mean local per-pixel background times the circular aperture area. If you are using the default "exact" aperture (see :ref:`aperture-mask methods `) and there are no masked pixels, the exact analytical aperture area can be accessed via the aperture ``area`` attribute:: >>> aperture.area # doctest: +FLOAT_CMP 78.53981633974483 However, in general you should use the :meth:`photutils.aperture.PixelAperture.area_overlap` method where a ``mask`` keyword can be input. This ensures you are using the same area over which the photometry was performed. If using a :class:`~photutils.aperture.SkyAperture`, you will first need to convert it to a :class:`~photutils.aperture.PixelAperture`. Since we are not using a mask, the results are identical:: >>> aperture_area = aperture.area_overlap(data) >>> print(aperture_area) # doctest: +FLOAT_CMP [78.53981634 78.53981634 78.53981634] The total background within the circular aperture is then:: >>> total_bkg = bkg_mean * aperture_area >>> print(total_bkg) # doctest: +FLOAT_CMP [392.23708187 403.29680431 382.40617574] Thus, the background-subtracted photometry is:: >>> phot_bkgsub = phot_table['aperture_sum'] - total_bkg Finally, let's add these as columns to the photometry table:: >>> phot_table['total_bkg'] = total_bkg >>> phot_table['aperture_sum_bkgsub'] = phot_bkgsub >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> print(phot_table) id xcenter ycenter aperture_sum total_bkg aperture_sum_bkgsub --- ------- ------- ------------ --------- ------------------- 1 145.1 168.3 1128.1245 392.23708 735.88739 2 84.5 224.1 735.739 403.2968 332.44219 3 48.3 200.3 1299.6341 382.40618 917.22792 Sigma-clipped median within a circular annulus """""""""""""""""""""""""""""""""""""""""""""" For this example, the local background level around each source is estimated as the sigma-clipped median value within the circular annulus. We'll use the :class:`~photutils.aperture.ApertureStats` class to compute both the photometry (aperture sum) and the background level:: >>> from astropy.stats import SigmaClip >>> sigclip = SigmaClip(sigma=3.0, maxiters=10) >>> aper_stats = ApertureStats(data, aperture, sigma_clip=None) >>> bkg_stats = ApertureStats(data, annulus_aperture, sigma_clip=sigclip) The sigma-clipped median values in the background annulus apertures are:: >>> print(bkg_stats.median) # doctest: +FLOAT_CMP [4.89374178 5.05655328 4.83268958] The total background within the circular apertures is then the per-pixel background level multiplied by the circular-aperture areas:: >>> total_bkg = bkg_stats.median * aper_stats.sum_aper_area.value >>> print(total_bkg) # doctest: +FLOAT_CMP [384.35358069 397.14076611 379.5585524 ] Finally, the local background-subtracted sum within the circular apertures is:: >>> apersum_bkgsub = aper_stats.sum - total_bkg >>> print(apersum_bkgsub) # doctest: +FLOAT_CMP [743.77088731 338.59823118 920.07553956] Note that if you want to compute all the source properties (i.e., in addition to only :attr:`~photutils.aperture.ApertureStats.sum`) on the local-background-subtracted data, you may input the *per-pixel* local background values to :class:`~photutils.aperture.ApertureStats` via the ``local_bkg`` keyword:: >>> aper_stats_bkgsub = ApertureStats(data, aperture, ... local_bkg=bkg_stats.median) >>> print(aper_stats_bkgsub.sum) # doctest: +FLOAT_CMP [743.77088731 338.59823118 920.07553956] Note these background-subtracted values are the same as those above. .. _error_estimation: Aperture Photometry Error Estimation ------------------------------------ If and only if the ``error`` keyword is input to :func:`~photutils.aperture.aperture_photometry`, the returned table will include a ``'aperture_sum_err'`` column in addition to ``'aperture_sum'``. ``'aperture_sum_err'`` provides the propagated uncertainty associated with ``'aperture_sum'``. For example, suppose we have previously calculated the error on each pixel value and saved it in the array ``error``:: >>> positions = [(30.0, 30.0), (40.0, 40.0)] >>> aperture = CircularAperture(positions, r=3.0) >>> data = np.ones((100, 100)) >>> error = 0.1 * data >>> phot_table = aperture_photometry(data, aperture, error=error) >>> for col in phot_table.colnames: ... phot_table[col].info.format = '%.8g' # for consistent table output >>> print(phot_table) id xcenter ycenter aperture_sum aperture_sum_err --- ------- ------- ------------ ---------------- 1 30 30 28.274334 0.53173616 2 40 40 28.274334 0.53173616 ``'aperture_sum_err'`` values are given by: .. math:: \Delta F = \sqrt{\sum_{i \in A} \sigma_{\mathrm{tot}, i}^2} where :math:`A` are the non-masked pixels in the aperture, and :math:`\sigma_{\mathrm{tot}, i}` is the input ``error`` array. In the example above, it is assumed that the ``error`` keyword specifies the *total* error --- either it includes Poisson noise due to individual sources or such noise is irrelevant. However, it is often the case that one has calculated a smooth "background-only error" array, which by design doesn't include increased noise on bright pixels. To include Poisson noise from the sources, we can use the :func:`~photutils.utils.calc_total_error` function. Let's assume we have a background-only image called ``bkg_error``. If our data are in units of electrons/s, we would use the exposure time as the effective gain:: >>> from photutils.utils import calc_total_error >>> effective_gain = 500 # seconds >>> error = calc_total_error(data, bkg_error, effective_gain) # doctest: +SKIP >>> phot_table = aperture_photometry(data - bkg, aperture, error=error) # doctest: +SKIP Aperture Photometry with Pixel Masking -------------------------------------- Pixels can be ignored/excluded (e.g., bad pixels) from the aperture photometry by providing an image mask via the ``mask`` keyword:: >>> data = np.ones((5, 5)) >>> aperture = CircularAperture((2, 2), 2.0) >>> mask = np.zeros(data.shape, dtype=bool) >>> data[2, 2] = 100.0 # bad pixel >>> mask[2, 2] = True >>> t1 = aperture_photometry(data, aperture, mask=mask) >>> t1['aperture_sum'].info.format = '%.8g' # for consistent table output >>> print(t1['aperture_sum']) aperture_sum ------------ 11.566371 The result is very different if a ``mask`` image is not provided:: >>> t2 = aperture_photometry(data, aperture) >>> t2['aperture_sum'].info.format = '%.8g' # for consistent table output >>> print(t2['aperture_sum']) aperture_sum ------------ 111.56637 Aperture Photometry Using Sky Coordinates ----------------------------------------- As mentioned in :ref:`creating-aperture-objects`, performing photometry using apertures defined in sky coordinates simply requires defining a "sky" aperture at positions defined by a :class:`~astropy.coordinates.SkyCoord` object. Here we show an example of photometry on real data using a `~photutils.aperture.SkyCircularAperture`. We start by loading a Spitzer 4.5 micron image of a region of the Galactic plane:: >>> import astropy.units as u >>> from astropy.wcs import WCS >>> from photutils.datasets import load_spitzer_catalog, load_spitzer_image >>> hdu = load_spitzer_image() # doctest: +REMOTE_DATA >>> data = u.Quantity(hdu.data, unit=hdu.header['BUNIT']) # doctest: +REMOTE_DATA >>> wcs = WCS(hdu.header) # doctest: +REMOTE_DATA >>> catalog = load_spitzer_catalog() # doctest: +REMOTE_DATA The catalog contains (among other things) the Galactic coordinates of the sources in the image as well as the PSF-fitted fluxes from the official Spitzer data reduction. We define the apertures positions based on the existing catalog positions:: >>> positions = SkyCoord(catalog['l'], catalog['b'], frame='galactic') # doctest: +REMOTE_DATA >>> aperture = SkyCircularAperture(positions, r=4.8 * u.arcsec) # doctest: +REMOTE_DATA Now perform the photometry in these apertures on the ``data``. The ``wcs`` object contains the WCS transformation of the image obtained from the FITS header. It includes the coordinate frame of the image and the projection from sky to pixel coordinates. The `~photutils.aperture.aperture_photometry` function uses the WCS information to automatically convert the apertures defined in sky coordinates into pixel coordinates:: >>> phot_table = aperture_photometry(data, aperture, wcs=wcs) # doctest: +REMOTE_DATA The Spitzer catalog also contains the official fluxes for the sources, so we can compare to our fluxes. Because the Spitzer catalog fluxes are in units of mJy and the data are in units of MJy/sr, we need to convert units before comparing the results. The image data have a pixel scale of 1.2 arcsec/pixel. >>> import astropy.units as u >>> factor = (1.2 * u.arcsec) ** 2 / u.pixel >>> fluxes_catalog = catalog['f4_5'] # doctest: +REMOTE_DATA >>> converted_aperture_sum = (phot_table['aperture_sum'] * ... factor).to(u.mJy / u.pixel) # doctest: +REMOTE_DATA Finally, we can plot the comparison of the photometry: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> plt.scatter(fluxes_catalog, converted_aperture_sum.value) >>> plt.xlabel('Spitzer catalog PSF-fit fluxes ') >>> plt.ylabel('Aperture photometry fluxes') .. plot:: import matplotlib.pyplot as plt from astropy import units as u from astropy.coordinates import SkyCoord from astropy.wcs import WCS from photutils.aperture import SkyCircularAperture, aperture_photometry from photutils.datasets import load_spitzer_catalog, load_spitzer_image # Load dataset hdu = load_spitzer_image() data = u.Quantity(hdu.data, unit=hdu.header['BUNIT']) wcs = WCS(hdu.header) catalog = load_spitzer_catalog() # Set up apertures positions = SkyCoord(catalog['l'], catalog['b'], frame='galactic') aperture = SkyCircularAperture(positions, r=4.8 * u.arcsec) phot_table = aperture_photometry(data, aperture, wcs=wcs) # Convert to correct units factor = (1.2 * u.arcsec) ** 2 / u.pixel fluxes_catalog = catalog['f4_5'] converted_aperture_sum = (phot_table['aperture_sum'] * factor).to(u.mJy / u.pixel) # Plot plt.scatter(fluxes_catalog, converted_aperture_sum.value) plt.xlabel('Spitzer catalog PSF-fit fluxes ') plt.ylabel('Aperture photometry fluxes') plt.plot([40, 100, 450], [40, 100, 450], color='black', lw=2) Despite using different methods, the two catalogs are in good agreement. The aperture photometry fluxes are based on a circular aperture with a radius of 4.8 arcsec. The Spitzer catalog fluxes were computed using PSF photometry. Therefore, differences are expected between the two measurements. Aperture Masks -------------- All `~photutils.aperture.PixelAperture` objects have a :meth:`~photutils.aperture.PixelAperture.to_mask` method that returns a `~photutils.aperture.ApertureMask` object (for a single aperture position) or a list of `~photutils.aperture.ApertureMask` objects, one for each aperture position. The `~photutils.aperture.ApertureMask` object contains a cutout of the aperture mask weights and a `~photutils.aperture.BoundingBox` object that provides the bounding box where the mask is to be applied. Let's start by creating a circular-annulus aperture:: >>> from photutils.aperture import CircularAnnulus >>> from photutils.datasets import make_100gaussians_image >>> data = make_100gaussians_image() >>> positions = [(145.1, 168.3), (84.5, 224.1), (48.3, 200.3)] >>> aperture = CircularAnnulus(positions, r_in=10, r_out=15) Now let's create a list of `~photutils.aperture.ApertureMask` objects using the :meth:`~photutils.aperture.PixelAperture.to_mask` method using the aperture mask "exact" method:: >>> masks = aperture.to_mask(method='exact') Let's plot the first aperture mask: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> plt.imshow(masks[0]) .. plot:: import matplotlib.pyplot as plt from photutils.aperture import CircularAnnulus, CircularAperture from photutils.datasets import make_100gaussians_image data = make_100gaussians_image() positions = [(145.1, 168.3), (84.5, 224.1), (48.3, 200.3)] aperture = CircularAperture(positions, r=5) annulus_aperture = CircularAnnulus(positions, r_in=10, r_out=15) masks = annulus_aperture.to_mask(method='exact') plt.imshow(masks[0]) Let's now use the "center" aperture mask method and plot the resulting aperture mask: .. doctest-skip:: >>> masks2 = aperture.to_mask(method='center') >>> plt.imshow(masks2[0]) .. plot:: import matplotlib.pyplot as plt from photutils.aperture import CircularAnnulus, CircularAperture from photutils.datasets import make_100gaussians_image data = make_100gaussians_image() positions = [(145.1, 168.3), (84.5, 224.1), (48.3, 200.3)] aperture = CircularAperture(positions, r=5) annulus_aperture = CircularAnnulus(positions, r_in=10, r_out=15) masks2 = annulus_aperture.to_mask(method='center') plt.imshow(masks2[0]) We can also create an aperture mask-weighted cutout from the data, properly handling the cases of partial or no overlap of the aperture mask with the data. Let's plot the aperture mask weights (using the mask generated above with the "exact" method) multiplied with the data: .. doctest-skip:: >>> data_weighted = masks[0].multiply(data) >>> plt.imshow(data_weighted) .. plot:: import matplotlib.pyplot as plt from photutils.aperture import CircularAnnulus, CircularAperture from photutils.datasets import make_100gaussians_image data = make_100gaussians_image() positions = [(145.1, 168.3), (84.5, 224.1), (48.3, 200.3)] aperture = CircularAperture(positions, r=5) annulus_aperture = CircularAnnulus(positions, r_in=10, r_out=15) masks = annulus_aperture.to_mask(method='exact') plt.imshow(masks[0].multiply(data)) To get a 1D `~numpy.ndarray` of the non-zero weighted data values, use the :meth:`~photutils.aperture.ApertureMask.get_values` method: .. doctest-skip:: >>> data_weighted_1d = masks[0].get_values(data) The :class:`~photutils.aperture.ApertureMask` class also provides a :meth:`~photutils.aperture.ApertureMask.to_image` method to obtain an image of the aperture mask in a 2D array of the given shape and a :meth:`~photutils.aperture.ApertureMask.cutout` method to create a cutout from the input data over the aperture mask bounding box. Both of these methods properly handle the cases of partial or no overlap of the aperture mask with the data. .. _custom-apertures: Defining Your Own Custom Apertures ---------------------------------- The :func:`~photutils.aperture.aperture_photometry` function can perform aperture photometry in arbitrary apertures. This function accepts any `~photutils.aperture.Aperture`-derived objects, such as `~photutils.aperture.CircularAperture`. This makes it simple to extend functionality: a new type of aperture photometry simply requires the definition of a new `~photutils.aperture.Aperture` subclass. All `~photutils.aperture.PixelAperture` subclasses must define a ``bounding_boxes`` property and ``to_mask()`` and ``plot()`` methods. They may also optionally define an ``area`` property. All `~photutils.aperture.SkyAperture` subclasses must only implement a ``to_pixel()`` method. * ``bounding_boxes``: The minimal bounding box for the aperture. If the aperture is scalar, then a single `~photutils.aperture.BoundingBox` is returned. Otherwise, a list of `~photutils.aperture.BoundingBox` is returned. * ``area``: An optional property defining the exact analytical area (in pixels**2) of the aperture. * ``to_mask()``: Return a mask for the aperture. If the aperture is scalar, then a single `~photutils.aperture.ApertureMask` is returned. Otherwise, a list of `~photutils.aperture.ApertureMask` is returned. * ``plot()``: A method to plot the aperture on a `matplotlib.axes.Axes` instance. API Reference ------------- :doc:`../reference/aperture_api` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/user_guide/background.rst0000644000175100001660000005115114755160622021120 0ustar00runnerdocker.. _background: Background Estimation (`photutils.background`) ============================================== Introduction ------------ To accurately measure the photometry and morphological properties of astronomical sources, one requires an accurate estimate of the background, which can be from both the sky and the detector. Similarly, having an accurate estimate of the background noise is important for determining the significance of source detections and for estimating photometric errors. Unfortunately, accurate background and background noise estimation is a difficult task. Further, because astronomical images can cover a wide variety of scenes, there is not a single background estimation method that will always be applicable. Photutils provides tools for estimating the background and background noise in your data, but they will likely require some tweaking to optimize the background estimate for your data. Scalar Background and Noise Estimation -------------------------------------- Simple Statistics ^^^^^^^^^^^^^^^^^ If the background level and noise are relatively constant across an image, the simplest way to estimate these values is to derive scalar quantities using simple approximations. When computing the image statistics one must take into account the astronomical sources present in the images, which add a positive tail to the distribution of pixel intensities. For example, one may consider using the image median as the background level and the image standard deviation as the 1-sigma background noise, but the resulting values are biased by the presence of real sources. A slightly better method involves using statistics that are robust against the presence of outliers, such as the biweight location for the background level and biweight scale or normalized `median absolute deviation (MAD) `__ for the background noise estimation. However, for most astronomical scenes these methods will also be biased by the presence of astronomical sources in the image. As an example, we load a synthetic image comprised of 100 sources with a Gaussian-distributed background whose mean is 5 and standard deviation is 2:: >>> from photutils.datasets import make_100gaussians_image >>> data = make_100gaussians_image() Let's plot the image: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> from astropy.visualization import SqrtStretch >>> from astropy.visualization.mpl_normalize import ImageNormalize >>> norm = ImageNormalize(stretch=SqrtStretch()) >>> plt.imshow(data, norm=norm, origin='lower', cmap='Greys_r', ... interpolation='nearest') .. plot:: import matplotlib.pyplot as plt from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.datasets import make_100gaussians_image data = make_100gaussians_image() norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(data, norm=norm, origin='lower', cmap='Greys_r', interpolation='nearest') plt.title('Data') The image median and biweight location are both larger than the true background level of 5:: >>> import numpy as np >>> from astropy.stats import biweight_location >>> print(np.median(data)) # doctest: +FLOAT_CMP 5.222396450477202 >>> print(biweight_location(data)) # doctest: +FLOAT_CMP 5.187556942771537 Similarly, using the median absolute deviation to estimate the background noise level gives a value that is larger than the true value of 2:: >>> from astropy.stats import mad_std >>> print(mad_std(data)) # doctest: +FLOAT_CMP 2.1497096320053166 Sigma Clipping Sources ^^^^^^^^^^^^^^^^^^^^^^ The most widely used technique to remove the sources from the image statistics is called sigma clipping. Briefly, pixels that are above or below a specified sigma level from the median are discarded and the statistics are recalculated. The procedure is typically repeated over a number of iterations or until convergence is reached. This method provides a better estimate of the background and background noise levels:: >>> from astropy.stats import sigma_clipped_stats >>> mean, median, std = sigma_clipped_stats(data, sigma=3.0) >>> print(np.array((mean, median, std))) # doctest: +FLOAT_CMP [5.19968673 5.15244174 2.09423739] Masking Sources ^^^^^^^^^^^^^^^ An even better procedure is to exclude the sources in the image by masking them. This technique requires one to :ref:`identify the sources in the data `, which in turn depends on the background and background noise. Therefore, this method for estimating the background and background RMS requires an iterative procedure. One method to create a source mask is to use a :ref:`segmentation image `. Here we use the `~photutils.segmentation.detect_threshold` convenience function to get a rough estimate of the threshold at the 2-sigma background noise level. Then we use the `~photutils.segmentation.detect_sources` function to generate a `~photutils.segmentation.SegmentationImage`. Finally, we use the :meth:`~photutils.segmentation.SegmentationImage.make_source_mask` method with a circular dilation footprint to create the source mask:: >>> from astropy.stats import sigma_clipped_stats, SigmaClip >>> from photutils.segmentation import detect_threshold, detect_sources >>> from photutils.utils import circular_footprint >>> sigma_clip = SigmaClip(sigma=3.0, maxiters=10) >>> threshold = detect_threshold(data, nsigma=2.0, sigma_clip=sigma_clip) >>> segment_img = detect_sources(data, threshold, npixels=10) >>> footprint = circular_footprint(radius=10) >>> mask = segment_img.make_source_mask(footprint=footprint) >>> mean, median, std = sigma_clipped_stats(data, sigma=3.0, mask=mask) >>> print(np.array((mean, median, std))) # doctest: +FLOAT_CMP [5.00257401 4.99641799 1.97009566] The source detection and masking procedure can be iterated further. Even with one iteration we are within 0.2% of the true background value and 1.5% of the true background RMS. 2D Background and Noise Estimation ---------------------------------- If the background or the background noise varies across the image, then you will generally want to generate a 2D image of the background and background RMS (or compute these values locally). This can be accomplished by applying the above techniques to subregions of the image. A common procedure is to use sigma-clipped statistics in each mesh of a grid that covers the input data to create a low-resolution background image. The final background or background RMS image can then be generated by interpolating the low-resolution image. Photutils provides the :class:`~photutils.background.Background2D` class to estimate the 2D background and background noise in an astronomical image. :class:`~photutils.background.Background2D` requires the size of the box (``box_size``) in which to estimate the background. Selecting the box size requires some care by the user. The box size should generally be larger than the typical size of sources in the image, but small enough to encapsulate any background variations. For best results, the box size should also be chosen so that the data are covered by an integer number of boxes in both dimensions. If that is not the case, the ``edge_method`` keyword determines whether to pad or crop the image such that there is an integer multiple of the ``box_size`` in both dimensions. The background level in each of the meshes is calculated using the function or callable object (e.g., class instance) input via ``bkg_estimator`` keyword. Photutils provides a several background classes that can be used: * `~photutils.background.MeanBackground` * `~photutils.background.MedianBackground` * `~photutils.background.ModeEstimatorBackground` * `~photutils.background.MMMBackground` * `~photutils.background.SExtractorBackground` * `~photutils.background.BiweightLocationBackground` The default is a `~photutils.background.SExtractorBackground` instance. For this method, the background in each mesh is calculated as ``(2.5 * median) - (1.5 * mean)``. However, if ``(mean - median) / std > 0.3`` then the ``median`` is used instead. Likewise, the background RMS level in each mesh is calculated using the function or callable object input via the ``bkgrms_estimator`` keyword. Photutils provides the following classes for this purpose: * `~photutils.background.StdBackgroundRMS` * `~photutils.background.MADStdBackgroundRMS` * `~photutils.background.BiweightScaleBackgroundRMS` For even more flexibility, users may input a custom function or callable object to the ``bkg_estimator`` and/or ``bkgrms_estimator`` keywords. By default, the ``bkg_estimator`` and ``bkgrms_estimator`` are applied to sigma clipped data. Sigma clipping is defined by inputting a :class:`astropy.stats.SigmaClip` object to the ``sigma_clip`` keyword. The default is to perform sigma clipping with ``sigma=3`` and ``maxiters=10``. Sigma clipping can be turned off by setting ``sigma_clip=None``. After the background level has been determined in each of the boxes, the low-resolution background image can be median filtered, with a window of size of ``filter_size``, to suppress local under or over estimations (e.g., due to bright galaxies in a particular box). Likewise, the median filter can be applied only to those boxes where the background level is above a specified threshold (``filter_threshold``). The low-resolution background and background RMS images are resized to the original data size using the function or callable object input via the ``interpolator`` keyword. Photutils provides two interpolator classes: :class:`~photutils.background.BkgZoomInterpolator` (default), which performs spline interpolation, and :class:`~photutils.background.BkgIDWInterpolator`, which uses inverse-distance weighted (IDW) interpolation. For this example, we will create a test image by adding a strong background gradient to the image defined above:: >>> ny, nx = data.shape >>> y, x = np.mgrid[:ny, :nx] >>> gradient = x * y / 5000.0 >>> data2 = data + gradient >>> plt.imshow(data2, norm=norm, origin='lower', cmap='Greys_r', ... interpolation='nearest') # doctest: +SKIP .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.datasets import make_100gaussians_image data = make_100gaussians_image() ny, nx = data.shape y, x = np.mgrid[:ny, :nx] gradient = x * y / 5000.0 data2 = data + gradient norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(data2, norm=norm, origin='lower', cmap='Greys_r', interpolation='nearest') plt.title('Data with added background gradient') We start by creating a `~photutils.background.Background2D` object using a box size of 50x50 and a 3x3 median filter. We will estimate the background level in each mesh as the sigma-clipped median using an instance of :class:`~photutils.background.MedianBackground`:: >>> from astropy.stats import SigmaClip >>> from photutils.background import Background2D, MedianBackground >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkg_estimator = MedianBackground() >>> bkg = Background2D(data2, (50, 50), filter_size=(3, 3), ... sigma_clip=sigma_clip, bkg_estimator=bkg_estimator) The 2D background and background RMS images are retrieved using the ``background`` and ``background_rms`` attributes, respectively, on the returned object. The low-resolution versions of these images are stored in the ``background_mesh`` and ``background_rms_mesh`` attributes, respectively. The global median value of the low-resolution background and background RMS image can be accessed with the ``background_median`` and ``background_rms_median`` attributes, respectively:: >>> print(bkg.background_median) # doctest: +FLOAT_CMP 10.852487630351824 >>> print(bkg.background_rms_median) # doctest: +FLOAT_CMP 2.262996981325314 Let's plot the background image: .. doctest-skip:: >>> plt.imshow(bkg.background, origin='lower', cmap='Greys_r', ... interpolation='nearest') .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.stats import SigmaClip from photutils.background import Background2D, MedianBackground from photutils.datasets import make_100gaussians_image data = make_100gaussians_image() ny, nx = data.shape y, x = np.mgrid[:ny, :nx] gradient = x * y / 5000.0 data2 = data + gradient sigma_clip = SigmaClip(sigma=3.0) bkg_estimator = MedianBackground() bkg = Background2D(data2, (50, 50), filter_size=(3, 3), sigma_clip=sigma_clip, bkg_estimator=bkg_estimator) plt.imshow(bkg.background, origin='lower', cmap='Greys_r', interpolation='nearest') plt.title('Estimated Background') and the background-subtracted image: .. doctest-skip:: >>> plt.imshow(data2 - bkg.background, norm=norm, origin='lower', ... cmap='Greys_r', interpolation='nearest') .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.stats import SigmaClip from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.background import Background2D, MedianBackground from photutils.datasets import make_100gaussians_image data = make_100gaussians_image() ny, nx = data.shape y, x = np.mgrid[:ny, :nx] gradient = x * y / 5000.0 data2 = data + gradient sigma_clip = SigmaClip(sigma=3.0) bkg_estimator = MedianBackground() bkg = Background2D(data2, (50, 50), filter_size=(3, 3), sigma_clip=sigma_clip, bkg_estimator=bkg_estimator) norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(data2 - bkg.background, norm=norm, origin='lower', cmap='Greys_r', interpolation='nearest') plt.title('Background-subtracted Data') Masking ^^^^^^^ Masks can also be input into `~photutils.background.Background2D`. The ``mask`` keyword can be used to mask sources or bad pixels in the image prior to estimating the background levels. Additionally, the ``coverage_mask`` keyword can be used to mask blank regions without data coverage (e.g., from a rotated image or an image from a mosaic). Otherwise, the data values in the regions without coverage (usually zeros or NaNs) will adversely affect the background statistics. Unlike ``mask``, ``coverage_mask`` is applied to the output background and background RMS maps. The ``fill_value`` keyword defines the value assigned in the output background and background RMS maps where the input ``coverage_mask`` is `True`. Let's create a rotated image that has blank areas and plot it:: >>> from scipy.ndimage import rotate >>> data3 = rotate(data2, -45.0) >>> norm = ImageNormalize(stretch=SqrtStretch()) # doctest: +SKIP >>> plt.imshow(data3, origin='lower', cmap='Greys_r', norm=norm, ... interpolation='nearest') # doctest: +SKIP .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.datasets import make_100gaussians_image from scipy.ndimage import rotate data = make_100gaussians_image() ny, nx = data.shape y, x = np.mgrid[:ny, :nx] gradient = x * y / 5000.0 data2 = data + gradient data3 = rotate(data2, -45.0) norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(data3, origin='lower', cmap='Greys_r', norm=norm, interpolation='nearest') plt.title('Data with added background gradient') Now we create a coverage mask and input it into `~photutils.background.Background2D` to exclude the regions where we have no data. For this example, we set the ``fill_value`` to 0.0. For real data, one can usually create a coverage mask from a weight or noise image. In this example we also use a smaller box size to help capture the strong gradient in the background. We also increase the value of the ``exclude_percentile`` keyword to include more boxes around the edge of the rotated image:: >>> 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``):: >>> norm = ImageNormalize(stretch=SqrtStretch()) # doctest: +SKIP >>> plt.imshow(bkg3.background, origin='lower', cmap='Greys_r', norm=norm, ... interpolation='nearest') # doctest: +SKIP .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.background import Background2D from photutils.datasets import make_100gaussians_image from scipy.ndimage import rotate data = make_100gaussians_image() ny, nx = data.shape y, x = np.mgrid[:ny, :nx] gradient = x * y / 5000.0 data2 = data + gradient data3 = rotate(data2, -45.0) coverage_mask = (data3 == 0) bkg3 = Background2D(data3, (15, 15), filter_size=(3, 3), coverage_mask=coverage_mask, fill_value=0.0, exclude_percentile=50.0) norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(bkg3.background, origin='lower', cmap='Greys_r', norm=norm, interpolation='nearest') plt.title('Estimated Background') Finally, let's subtract the background from the image and plot it: .. doctest-skip:: >>> norm = ImageNormalize(stretch=SqrtStretch()) >>> plt.imshow(data3 - bkg3.background, origin='lower', cmap='Greys_r', ... norm=norm, interpolation='nearest') .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.background import Background2D from photutils.datasets import make_100gaussians_image from scipy.ndimage import rotate data = make_100gaussians_image() ny, nx = data.shape y, x = np.mgrid[:ny, :nx] gradient = x * y / 5000.0 data2 = data + gradient data3 = rotate(data2, -45.0) coverage_mask = (data3 == 0) bkg3 = Background2D(data3, (15, 15), filter_size=(3, 3), coverage_mask=coverage_mask, fill_value=0.0, exclude_percentile=50.0) norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(data3 - bkg3.background, origin='lower', cmap='Greys_r', norm=norm, interpolation='nearest') plt.title('Background-subtracted Data') If there is any small residual background still present in the image, the background subtraction can be improved by masking the sources and/or through further iterations. Plotting Meshes ^^^^^^^^^^^^^^^ Finally, the meshes that were used in generating the 2D background can be plotted on the original image using the :meth:`~photutils.background.Background2D.plot_meshes` method. Here we zoom in on a small portion of the image to show the background meshes. Meshes without a center marker were excluded. .. doctest-skip:: >>> plt.imshow(data3, origin='lower', cmap='Greys_r', norm=norm, ... interpolation='nearest') >>> bkg3.plot_meshes(outlines=True, marker='.', color='cyan', alpha=0.3) >>> plt.xlim(0, 250) >>> plt.ylim(0, 250) .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.background import Background2D from photutils.datasets import make_100gaussians_image from scipy.ndimage import rotate data = make_100gaussians_image() ny, nx = data.shape y, x = np.mgrid[:ny, :nx] gradient = x * y / 5000.0 data2 = data + gradient data3 = rotate(data2, -45.0) coverage_mask = (data3 == 0) bkg3 = Background2D(data3, (15, 15), filter_size=(3, 3), coverage_mask=coverage_mask, fill_value=0.0, exclude_percentile=50.0) norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(data3, origin='lower', cmap='Greys_r', norm=norm, interpolation='nearest') bkg3.plot_meshes(outlines=True, marker='.', color='cyan', alpha=0.3) plt.xlim(0, 250) plt.ylim(0, 250) API Reference ------------- :doc:`../reference/background_api` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/user_guide/centroids.rst0000644000175100001660000001567114755160622021002 0ustar00runnerdockerCentroids (`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 object "center of mass" from 2D image moments. * :func:`~photutils.centroids.centroid_quadratic`: Calculates the centroid by fitting a 2D quadratic polynomial to the data. * :func:`~photutils.centroids.centroid_1dg`: Calculates the centroid by fitting 1D Gaussians to the marginal ``x`` and ``y`` distributions of the data. * :func:`~photutils.centroids.centroid_2dg`: Calculates the centroid by fitting a 2D Gaussian to the 2D distribution of the data. Masks can be input into each of these functions to mask bad pixels. Error arrays can be input into the two Gaussian fitting methods to weight the fits. Non-finite values (e.g., NaN or inf) in the data or error arrays are automatically masked To calculate the centroids of many sources in an image, use the :func:`~photutils.centroids.centroid_sources` function. This function can be used with any of the above centroiding functions or a custom user-defined centroiding function. Centroid of single source ------------------------- Let's extract a single object from a synthetic dataset and find its centroid with each of these methods. First, let's create the data:: >>> import numpy as np >>> from photutils.datasets import make_4gaussians_image >>> from photutils.centroids import (centroid_1dg, centroid_2dg, ... centroid_com, centroid_quadratic) >>> data = make_4gaussians_image() .. plot:: import matplotlib.pyplot as plt from photutils.datasets import make_4gaussians_image data = make_4gaussians_image() plt.figure(figsize=(8, 4)) plt.imshow(data, origin='lower', interpolation='nearest') plt.tight_layout() Next, we need to subtract the background from the data. For this example, we'll estimate the background by taking the median of a blank part of the image:: >>> data -= np.median(data[0:30, 0:125]) The data is a 2D image of four Gaussian sources. Let's extract a single object from the data:: >>> data = data[40:80, 70:110] Now we can calculate the centroid of the object using each of the centroiding functions:: >>> x1, y1 = centroid_com(data) >>> print(np.array((x1, y1))) # doctest: +FLOAT_CMP [19.9796724 20.00992593] :: >>> x2, y2 = centroid_quadratic(data) >>> print(np.array((x2, y2))) # doctest: +FLOAT_CMP [19.94009505 20.06884997] :: >>> x3, y3 = centroid_1dg(data) >>> print(np.array((x3, y3))) # doctest: +FLOAT_CMP [19.96553246 20.04952841] :: >>> x4, y4 = centroid_2dg(data) >>> print(np.array((x4, y4))) # doctest: +FLOAT_CMP [19.98519436 20.0149016 ] The measured centroids are all very close to the true centroid of the object in the cutout image of ``(20, 20)``. Now let's plot the results. Because the centroids are all very similar, we also include an inset plot zoomed in near the centroid: .. plot:: import matplotlib.pyplot as plt import numpy as np from mpl_toolkits.axes_grid1.inset_locator import (mark_inset, zoomed_inset_axes) from photutils.centroids import (centroid_1dg, centroid_2dg, centroid_com, centroid_quadratic) from photutils.datasets import make_4gaussians_image data = make_4gaussians_image() data -= np.median(data[0:30, 0:125]) data = data[40:80, 70:110] xycen1 = centroid_com(data) xycen2 = centroid_quadratic(data) xycen3 = centroid_1dg(data) xycen4 = centroid_2dg(data) xycens = [xycen1, xycen2, xycen3, xycen4] fig, ax = plt.subplots(1, 1, figsize=(8, 8)) ax.imshow(data, origin='lower', interpolation='nearest') marker = '+' ms = 60 colors = ('white', 'cyan', 'red', 'blue') labels = ('Center of Mass', 'Quadratic', '1D Gaussian', '2D Gaussian') for xycen, color, label in zip(xycens, colors, labels): ax.scatter(*xycen, color=color, marker=marker, s=ms, label=label) ax.legend(loc='lower right', fontsize=12) ax2 = zoomed_inset_axes(ax, zoom=6, loc=9) ax2.imshow(data, vmin=190, vmax=220, origin='lower', interpolation='nearest') ms = 1000 for xycen, color in zip(xycens, colors): ax2.scatter(*xycen, color=color, marker=marker, s=ms) ax2.set_xlim(19, 21) ax2.set_ylim(19, 21) mark_inset(ax, ax2, loc1=3, loc2=4, fc='none', ec='black') ax2.axes.get_xaxis().set_visible(False) ax2.axes.get_yaxis().set_visible(False) ax.set_xlim(0, data.shape[1] - 1) ax.set_ylim(0, data.shape[0] - 1) Centroiding several sources in an image --------------------------------------- The :func:`~photutils.centroids.centroid_sources` function can be used to calculate the centroids of many sources in a single image given initial guesses for their central positions. This function can be used with any of the above centroiding functions or a custom user-defined centroiding function. For each source, a cutout image is made that is centered at each initial position of size ``box_size``. Optionally, a non-rectangular local ``footprint`` mask can be input instead of ``box_size``. The centroids for each source are then calculated within their cutout images:: >>> import numpy as np >>> from photutils.centroids import centroid_2dg, centroid_sources >>> from photutils.datasets import make_4gaussians_image >>> data = make_4gaussians_image() >>> data -= np.median(data[0:30, 0:125]) >>> x_init = (25, 91, 151, 160) >>> y_init = (40, 61, 24, 71) >>> x, y = centroid_sources(data, x_init, y_init, box_size=25, ... centroid_func=centroid_2dg) >>> print(x) # doctest: +FLOAT_CMP [ 24.96807828 89.98684636 149.96545721 160.18810915] >>> print(y) # doctest: +FLOAT_CMP [40.03657613 60.01836631 24.96777946 69.80208702] The measured centroids are all very close to the true centroids of the simulated objects in the image, which have ``(x, y)`` values of ``(25, 40)``, ``(90, 60)``, ``(150, 25)``, and ``(160, 70)``. Let's plot the results: .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.centroids import centroid_2dg, centroid_sources from photutils.datasets import make_4gaussians_image data = make_4gaussians_image() data -= np.median(data[0:30, 0:125]) x_init = (25, 91, 151, 160) y_init = (40, 61, 24, 71) x, y = centroid_sources(data, x_init, y_init, box_size=25, centroid_func=centroid_2dg) plt.figure(figsize=(8, 4)) plt.imshow(data, origin='lower', interpolation='nearest') plt.scatter(x, y, marker='+', s=80, color='red', label='Centroids') plt.legend() plt.tight_layout() API Reference ------------- :doc:`../reference/centroids_api` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/user_guide/datasets.rst0000644000175100001660000001260514755160622020612 0ustar00runnerdocker.. _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 ``load_*`` load datasets, either from within the Photutils package or remotely from a GitHub repository. Very small data files are bundled with Photutils and are guaranteed to be available. Larger datasets are available from the `astropy-data`_ repository. On first load, these larger datasets will be downloaded and placed into the Astropy cache on the user's machine. 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. Loading Datasets ---------------- Let's load an example image of M67 with :func:`~photutils.datasets.load_star_image`:: >>> from photutils.datasets import load_star_image >>> hdu = load_star_image() # doctest: +REMOTE_DATA >>> print(hdu.data.shape) # doctest: +REMOTE_DATA (1059, 1059) ``hdu`` is a FITS `~astropy.io.fits.ImageHDU` object and ``hdu.data`` is a `~numpy.ndarray` object. Let's plot the image: .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import load_star_image hdu = load_star_image() fig, ax = plt.subplots() ax.imshow(hdu.data, origin='lower', interpolation='nearest') Simulating Images ----------------- For this example, let's simulate an image of 2D Gaussian sources on a constant background with Gaussian noise. First, we'll create a table of 2D Gaussian source parameters with random positions, fluxes, and shapes using :func:`~photutils.datasets.make_model_params`:: >>> from photutils.datasets import make_model_params >>> from photutils.psf import GaussianPSF >>> model = GaussianPSF() >>> shape = (500, 500) >>> n_sources = 500 >>> params = make_model_params(shape, n_sources, x_name='x_0', ... y_name='y_0', min_separation=5, ... flux=(100, 500), x_fwhm=(1, 3), ... y_fwhm=(1, 3), theta=(0, 90), seed=123) Next, we'll create a simulated image of the sources using the table of model parameters using :func:`~photutils.datasets.make_model_image`:: >>> from photutils.datasets import make_model_image >>> model_shape = (25, 25) >>> data = make_model_image(shape, model, params, model_shape=model_shape, ... x_name='x_0', y_name='y_0') Next, let's add a constant background (``mean = 5``) and Gaussian noise (``stddev = 2``) to the image:: >>> from photutils.datasets import make_noise_image >>> noise = make_noise_image(shape, distribution='gaussian', mean=5, ... stddev=2, seed=123) >>> data += noise Finally, let's plot the simulated image: .. plot:: :include-source: import matplotlib.pyplot as plt from astropy.visualization import simple_norm from photutils.datasets import (make_model_image, make_model_params, make_noise_image) from photutils.psf import GaussianPSF model = GaussianPSF() shape = (500, 500) n_sources = 500 params = make_model_params(shape, n_sources, x_name='x_0', y_name='y_0', min_separation=5, flux=(100, 500), x_fwhm=(1, 3), y_fwhm=(1, 3), theta=(0, 90), seed=123) model_shape = (25, 25) data = make_model_image(shape, model, params, model_shape=model_shape, x_name='x_0', y_name='y_0') noise = make_noise_image(shape, distribution='gaussian', mean=5, stddev=2, seed=123) data += noise fig, ax = plt.subplots() norm = simple_norm(data, 'sqrt', percent=99) ax.imshow(data, norm=norm, origin='lower') ax.set_title('Simulated image') We can also create a simulated world coordinate system (WCS) for the image using :func:`~photutils.datasets.make_wcs`:: >>> from photutils.datasets import make_wcs >>> wcs = make_wcs(shape) >>> wcs.pixel_to_world(0, 0) or a generalized WCS using :func:`~photutils.datasets.make_gwcs`: .. doctest-requires:: gwcs >>> from photutils.datasets import make_gwcs >>> gwcs = make_gwcs(shape) >>> gwcs.pixel_to_world(0, 0) API Reference ------------- :doc:`../reference/datasets_api` .. _astropy-data: https://github.com/astropy/astropy-data/ .. _skymaker: https://github.com/astromatic/skymaker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/user_guide/detection.rst0000644000175100001660000002711514755160622020762 0ustar00runnerdocker.. _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 that are used to detect stars in an image, `DAOFIND`_ and IRAF's `starfind`_, plus a third tool that allows input of a custom user-defined kernel. :class:`~photutils.detection.DAOStarFinder` is a class that provides an implementation of the `DAOFIND`_ algorithm (`Stetson 1987, PASP 99, 191 `_). It searches images for local density maxima that have a peak amplitude greater than a specified threshold (the threshold is applied to a convolved image) and have a size and shape similar to a defined 2D Gaussian kernel. :class:`~photutils.detection.DAOStarFinder` also provides an estimate of the objects' roundness and sharpness, whose lower and upper bounds can be specified. :class:`~photutils.detection.IRAFStarFinder` is a class that implements IRAF's `starfind`_ algorithm. It is fundamentally similar to :class:`~photutils.detection.DAOStarFinder`, but :class:`~photutils.detection.DAOStarFinder` can use an elliptical Gaussian kernel. One other difference in :class:`~photutils.detection.IRAFStarFinder` is that it calculates the objects' centroid, roundness, and sharpness using image moments. :class:`~photutils.detection.StarFinder` is a class similar to :class:`~photutils.detection.IRAFStarFinder`, but which allows input of a custom user-defined kernel as a 2D array. This allows for more generalization beyond simple Gaussian kernels. As an example, let's load an image from the bundled datasets and select a subset of the image. We will estimate the background and background noise using sigma-clipped statistics:: >>> import numpy as np >>> from astropy.stats import sigma_clipped_stats >>> from photutils.datasets import load_star_image >>> hdu = load_star_image() # doctest: +REMOTE_DATA >>> data = hdu.data[0:401, 0:401] # doctest: +REMOTE_DATA >>> mean, median, std = sigma_clipped_stats(data, sigma=3.0) # doctest: +REMOTE_DATA >>> print(np.array((mean, median, std))) # doctest: +REMOTE_DATA, +FLOAT_CMP [3668.09661146 3649. 204.41388592] Now we will subtract the background and use an instance of :class:`~photutils.detection.DAOStarFinder` to find the stars in the image that have FWHMs of around 3 pixels and have peaks approximately 5-sigma above the background. Running this class on the data yields an astropy `~astropy.table.Table` containing the results of the star finder:: >>> from photutils.detection import DAOStarFinder >>> daofind = DAOStarFinder(fwhm=3.0, threshold=5.*std) # doctest: +REMOTE_DATA >>> sources = daofind(data - median) # doctest: +REMOTE_DATA >>> for col in sources.colnames: # doctest: +REMOTE_DATA ... if col not in ('id', 'npix'): ... sources[col].info.format = '%.2f' # for consistent table output >>> sources.pprint(max_width=76) # doctest: +REMOTE_DATA, +FLOAT_CMP id xcentroid ycentroid sharpness ... peak flux mag daofind_mag --- --------- --------- --------- ... ------- --------- ------ ----------- 1 144.25 6.38 0.58 ... 6903.00 45735.00 -11.65 -1.89 2 208.67 6.82 0.48 ... 7896.00 62118.00 -11.98 -2.07 3 216.93 6.58 0.69 ... 2195.00 12436.00 -10.24 -0.55 4 351.63 8.55 0.49 ... 6977.00 55313.00 -11.86 -1.93 5 377.52 12.07 0.52 ... 1260.00 9078.00 -9.89 -0.12 ... ... ... ... ... ... ... ... ... 282 267.90 398.62 0.27 ... 9299.00 147372.00 -12.92 -1.84 283 271.47 398.91 0.37 ... 8028.00 115913.00 -12.66 -1.76 284 299.05 398.78 0.26 ... 9072.00 140781.00 -12.87 -1.86 285 299.99 398.77 0.29 ... 9253.00 142233.00 -12.88 -1.82 286 360.45 399.52 0.37 ... 8079.00 81455.00 -12.28 -2.10 Length = 286 rows Let's plot the image and mark the location of detected sources: .. doctest-skip:: >>> import numpy as np >>> import matplotlib.pyplot as plt >>> from astropy.visualization import SqrtStretch >>> from astropy.visualization.mpl_normalize import ImageNormalize >>> from photutils.aperture import CircularAperture >>> positions = np.transpose((sources['xcentroid'], sources['ycentroid'])) >>> apertures = CircularAperture(positions, r=4.0) >>> norm = ImageNormalize(stretch=SqrtStretch()) >>> plt.imshow(data, cmap='Greys', origin='lower', norm=norm, ... interpolation='nearest') >>> apertures.plot(color='blue', lw=1.5, alpha=0.5) .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.stats import sigma_clipped_stats from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.aperture import CircularAperture from photutils.datasets import load_star_image from photutils.detection import DAOStarFinder hdu = load_star_image() data = hdu.data[0:401, 0:401] mean, median, std = sigma_clipped_stats(data, sigma=3.0) daofind = DAOStarFinder(fwhm=3.0, threshold=5.0 * std) sources = daofind(data - median) positions = np.transpose((sources['xcentroid'], sources['ycentroid'])) apertures = CircularAperture(positions, r=4.0) norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(data, cmap='Greys', origin='lower', norm=norm, interpolation='nearest') apertures.plot(color='blue', lw=1.5, alpha=0.5) Masking Regions ^^^^^^^^^^^^^^^ Regions of the input image can be masked by using the ``mask`` keyword with the :class:`~photutils.detection.DAOStarFinder` or :class:`~photutils.detection.IRAFStarFinder` instance. This simple examples uses :class:`~photutils.detection.DAOStarFinder` and masks two rectangular regions. No sources will be detected in the masked regions: .. doctest-skip:: >>> from photutils.detection import DAOStarFinder >>> daofind = DAOStarFinder(fwhm=3.0, threshold=5.0 * std) >>> mask = np.zeros(data.shape, dtype=bool) >>> mask[50:151, 50:351] = True >>> mask[250:351, 150:351] = True >>> sources = daofind(data - median, mask=mask) .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.stats import sigma_clipped_stats from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.aperture import CircularAperture, RectangularAperture from photutils.datasets import load_star_image from photutils.detection import DAOStarFinder hdu = load_star_image() data = hdu.data[0:401, 0:401] mean, median, std = sigma_clipped_stats(data, sigma=3.0) daofind = DAOStarFinder(fwhm=3.0, threshold=5.0 * std) mask = np.zeros(data.shape, dtype=bool) mask[50:151, 50:351] = True mask[250:351, 150:351] = True sources = daofind(data - median, mask=mask) positions = np.transpose((sources['xcentroid'], sources['ycentroid'])) apertures = CircularAperture(positions, r=4.0) norm = ImageNormalize(stretch=SqrtStretch()) plt.imshow(data, cmap='Greys', origin='lower', norm=norm, interpolation='nearest') plt.title('Star finder with a mask to exclude regions') apertures.plot(color='blue', lw=1.5, alpha=0.5) rect1 = RectangularAperture((200, 100), 300, 100, theta=0) rect2 = RectangularAperture((250, 300), 200, 100, theta=0) rect1.plot(color='salmon', ls='dashed') rect2.plot(color='salmon', ls='dashed') Local Peak Detection -------------------- Photutils also includes a :func:`~photutils.detection.find_peaks` function to find local peaks in an image that are above a specified threshold value. Peaks are the local maxima above a specified threshold that are separated by a specified minimum number of pixels, defined by a box size or a local footprint. The returned pixel coordinates for the peaks are always integer-valued (i.e., no centroiding is performed, only the peak pixel is identified). However, a centroiding function can be input via the ``centroid_func`` keyword to :func:`~photutils.detection.find_peaks` to also compute centroid coordinates with subpixel precision. As a simple example, let's find the local peaks in an image that are 5 sigma above the background and a separated by at least 5 pixels:: >>> from astropy.stats import sigma_clipped_stats >>> from photutils.datasets import make_100gaussians_image >>> from photutils.detection import find_peaks >>> data = make_100gaussians_image() >>> mean, median, std = sigma_clipped_stats(data, sigma=3.0) >>> threshold = median + (5.0 * std) >>> tbl = find_peaks(data, threshold, box_size=11) >>> tbl['peak_value'].info.format = '%.8g' # for consistent table output >>> print(tbl[:10]) # print only the first 10 peaks id x_peak y_peak peak_value --- ------ ------ ---------- 1 233 0 27.786048 2 493 6 18.699406 3 208 9 22.499317 4 259 11 16.400909 5 365 11 17.789691 6 290 23 34.141532 7 379 29 16.058562 8 442 31 32.162038 9 471 37 24.141928 10 358 39 18.671565 And let's plot the location of the detected peaks in the image: .. doctest-skip:: >>> import numpy as np >>> import matplotlib.pyplot as plt >>> from astropy.visualization import simple_norm >>> from astropy.visualization.mpl_normalize import ImageNormalize >>> from photutils.aperture import CircularAperture >>> positions = np.transpose((tbl['x_peak'], tbl['y_peak'])) >>> apertures = CircularAperture(positions, r=5.0) >>> norm = simple_norm(data, 'sqrt', percent=99.9) >>> plt.imshow(data, cmap='Greys_r', origin='lower', norm=norm, ... interpolation='nearest') >>> apertures.plot(color='#0547f9', lw=1.5) >>> plt.xlim(0, data.shape[1] - 1) >>> plt.ylim(0, data.shape[0] - 1) .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.stats import sigma_clipped_stats from astropy.visualization import simple_norm from photutils.aperture import CircularAperture from photutils.datasets import make_100gaussians_image from photutils.detection import find_peaks data = make_100gaussians_image() mean, median, std = sigma_clipped_stats(data, sigma=3.0) threshold = median + (5.0 * std) tbl = find_peaks(data, threshold, box_size=11) positions = np.transpose((tbl['x_peak'], tbl['y_peak'])) apertures = CircularAperture(positions, r=5.0) norm = simple_norm(data, 'sqrt', percent=99.9) plt.imshow(data, cmap='Greys_r', origin='lower', norm=norm, interpolation='nearest') apertures.plot(color='#0547f9', lw=1.5) plt.xlim(0, data.shape[1] - 1) plt.ylim(0, data.shape[0] - 1) API Reference ------------- :doc:`../reference/detection_api` .. _DAOFIND: https://iraf.net/irafhelp.php?val=daofind .. _starfind: https://iraf.net/irafhelp.php?val=starfind ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/user_guide/epsf.rst0000644000175100001660000003106314755160622017736 0ustar00runnerdocker.. _build-epsf: Building an effective Point Spread Function (ePSF) ================================================== The ePSF -------- The instrumental PSF is a combination of many factors that are generally difficult to model. `Anderson and King (2000; PASP 112, 1360) `_ showed that accurate stellar photometry and astrometry can be derived by modeling the net PSF, which they call the effective PSF (ePSF). The ePSF is an empirical model describing what fraction of a star's light will land in a particular pixel. The constructed ePSF is typically oversampled with respect to the detector pixels. Building an ePSF ---------------- Photutils provides tools for building an ePSF following the prescription of `Anderson and King (2000; PASP 112, 1360) `_ and subsequent enhancements detailed mainly in `Anderson (2016; WFC3 ISR 2016-12) `_. The process involves iterating between the ePSF itself and the stars used to build it. To begin, we must first define a large (e.g., several hundred) 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 large sample (e.g., several hundred) of stars in order to fully sample the PSF over the oversampled grid and to help reduce the effects of noise. Otherwise, the resulting ePSF may have holes or may be noisy. Let's start by loading a simulated HST/WFC3 image in the F160W band:: >>> from photutils.datasets import load_simulated_hst_star_image >>> hdu = load_simulated_hst_star_image() # doctest: +REMOTE_DATA >>> data = hdu.data # doctest: +REMOTE_DATA The simulated image does not contain any background or noise, so let's add those to the image:: >>> from photutils.datasets import make_noise_image >>> data += make_noise_image(data.shape, distribution='gaussian', ... mean=10.0, stddev=5.0, seed=123) # doctest: +REMOTE_DATA Let's show the image: .. plot:: :include-source: import matplotlib.pyplot as plt from astropy.visualization import simple_norm from photutils.datasets import (load_simulated_hst_star_image, make_noise_image) hdu = load_simulated_hst_star_image() data = hdu.data data += make_noise_image(data.shape, distribution='gaussian', mean=10.0, stddev=5.0, seed=123) norm = simple_norm(data, 'sqrt', percent=99.0) plt.imshow(data, norm=norm, origin='lower', cmap='viridis') For this example we'll use the :func:`~photutils.detection.find_peaks` function to identify the stars and their initial positions. We will not use the centroiding option in :func:`~photutils.detection.find_peaks` to simulate the effect of having imperfect initial guesses for the positions of the stars. Here we set the detection threshold value to 500.0 to select only the brightest stars:: >>> from photutils.detection import find_peaks >>> peaks_tbl = find_peaks(data, threshold=500.0) # doctest: +REMOTE_DATA >>> peaks_tbl['peak_value'].info.format = '%.8g' # for consistent table output # doctest: +REMOTE_DATA >>> print(peaks_tbl) # doctest: +REMOTE_DATA id x_peak y_peak peak_value --- ------ ------ ---------- 1 849 2 1076.7026 2 182 4 1709.5671 3 324 4 3006.0086 4 100 9 1142.9915 5 824 9 1302.8604 ... ... ... ... 427 751 992 801.23834 428 114 994 1595.2804 429 299 994 648.18539 430 207 998 2810.6503 431 691 999 2611.0464 Length = 431 rows Note that the stars are sufficiently separated in the simulated image that we do not need to exclude any stars due to crowding. In practice this step will require some manual inspection and selection. Next, we need to extract cutouts of the stars using the :func:`~photutils.psf.extract_stars` function. This function requires a table of star positions either in pixel or sky coordinates. For this example we are using the pixel coordinates, which need to be in table columns called simply ``x`` and ``y``. We plan to extract 25 x 25 pixel cutouts of our selected stars, so let's explicitly exclude stars that are too close to the image boundaries (because they cannot be extracted):: >>> size = 25 >>> hsize = (size - 1) / 2 >>> x = peaks_tbl['x_peak'] # doctest: +REMOTE_DATA >>> y = peaks_tbl['y_peak'] # doctest: +REMOTE_DATA >>> mask = ((x > hsize) & (x < (data.shape[1] -1 - hsize)) & ... (y > hsize) & (y < (data.shape[0] -1 - hsize))) # doctest: +REMOTE_DATA Now let's create the table of good star positions:: >>> from astropy.table import Table >>> stars_tbl = Table() >>> stars_tbl['x'] = x[mask] # doctest: +REMOTE_DATA >>> stars_tbl['y'] = y[mask] # doctest: +REMOTE_DATA The star cutouts from which we build the ePSF must have the background subtracted. Here we'll use the sigma-clipped median value as the background level. If the background in the image varies across the image, one should use more sophisticated methods (e.g., `~photutils.background.Background2D`). Let's subtract the background from the image:: >>> from astropy.stats import sigma_clipped_stats >>> mean_val, median_val, std_val = sigma_clipped_stats(data, sigma=2.0) # doctest: +REMOTE_DATA >>> data -= median_val # doctest: +REMOTE_DATA The :func:`~photutils.psf.extract_stars` function requires the input data as an `~astropy.nddata.NDData` object. An `~astropy.nddata.NDData` object is easy to create from our data array:: >>> from astropy.nddata import NDData >>> nddata = NDData(data=data) # doctest: +REMOTE_DATA We are now ready to create our star cutouts using the :func:`~photutils.psf.extract_stars` function. For this simple example we are extracting stars from a single image using a single catalog. The :func:`~photutils.psf.extract_stars` can also extract stars from multiple images using a separate catalog for each image or a single catalog. When using a single catalog, the star positions must be in sky coordinates (as `~astropy.coordinates.SkyCoord` objects) and the `~astropy.nddata.NDData` objects must contain valid `~astropy.wcs.WCS` objects. In the case of using multiple images (i.e., dithered images) and a single catalog, the same physical star will be "linked" across images, meaning it will be constrained to have the same sky coordinate in each input image. Let's extract the 25 x 25 pixel cutouts of our selected stars:: >>> from photutils.psf import extract_stars >>> stars = extract_stars(nddata, stars_tbl, size=25) # doctest: +REMOTE_DATA The function returns a `~photutils.psf.EPSFStars` object containing the cutouts of our selected stars. The function extracted 403 stars, from which we'll build our ePSF. Let's show the first 25 of them: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> from astropy.visualization import simple_norm >>> nrows = 5 >>> ncols = 5 >>> fig, ax = plt.subplots(nrows=nrows, ncols=ncols, figsize=(20, 20), ... squeeze=True) >>> ax = ax.ravel() >>> for i in range(nrows * ncols): ... norm = simple_norm(stars[i], 'log', percent=99.0) ... ax[i].imshow(stars[i], norm=norm, origin='lower', cmap='viridis') .. plot:: import matplotlib.pyplot as plt from astropy.nddata import NDData from astropy.stats import sigma_clipped_stats from astropy.table import Table from astropy.visualization import simple_norm from photutils.datasets import (load_simulated_hst_star_image, make_noise_image) from photutils.detection import find_peaks from photutils.psf import extract_stars hdu = load_simulated_hst_star_image() data = hdu.data data += make_noise_image(data.shape, distribution='gaussian', mean=10.0, stddev=5.0, seed=123) peaks_tbl = find_peaks(data, threshold=500.0) size = 25 hsize = (size - 1) / 2 x = peaks_tbl['x_peak'] y = peaks_tbl['y_peak'] mask = ((x > hsize) & (x < (data.shape[1] - 1 - hsize)) & (y > hsize) & (y < (data.shape[0] - 1 - hsize))) stars_tbl = Table() stars_tbl['x'] = x[mask] stars_tbl['y'] = y[mask] mean_val, median_val, std_val = sigma_clipped_stats(data, sigma=2.0) data -= median_val nddata = NDData(data=data) stars = extract_stars(nddata, stars_tbl, size=25) nrows = 5 ncols = 5 fig, ax = plt.subplots(nrows=nrows, ncols=ncols, figsize=(20, 20), squeeze=True) ax = ax.ravel() for i in range(nrows * ncols): norm = simple_norm(stars[i], 'log', percent=99.0) ax[i].imshow(stars[i], norm=norm, origin='lower', cmap='viridis') With the star cutouts in hand, we are ready to construct the ePSF with the :class:`~photutils.psf.EPSFBuilder` class. We'll create an ePSF with an oversampling factor of 4.0. Here we limit the maximum number of iterations to 3 (to limit its run time), but in practice one should use about 10 or more iterations. The :class:`~photutils.psf.EPSFBuilder` class has many other options to control the ePSF build process, including changing the centering function, the smoothing kernel, and the centering accuracy. Please see the :class:`~photutils.psf.EPSFBuilder` documentation for further details. We first initialize an :class:`~photutils.psf.EPSFBuilder` instance with our desired parameters and then input the cutouts of our selected stars to the instance:: >>> from photutils.psf import EPSFBuilder >>> epsf_builder = EPSFBuilder(oversampling=4, maxiters=3, ... progress_bar=False) # doctest: +REMOTE_DATA >>> epsf, fitted_stars = epsf_builder(stars) # doctest: +REMOTE_DATA The returned values are the ePSF, as an :class:`~photutils.psf.EPSFModel` object, and our input stars fitted with the constructed ePSF, as a new :class:`~photutils.psf.EPSFStars` object with fitted star positions and fluxes. Finally, let's show the constructed ePSF: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> from astropy.visualization import simple_norm >>> norm = simple_norm(epsf.data, 'log', percent=99.0) >>> plt.imshow(epsf.data, norm=norm, origin='lower', cmap='viridis') >>> plt.colorbar() .. plot:: import matplotlib.pyplot as plt from astropy.nddata import NDData from astropy.stats import sigma_clipped_stats from astropy.table import Table from astropy.visualization import simple_norm from photutils.datasets import (load_simulated_hst_star_image, make_noise_image) from photutils.detection import find_peaks from photutils.psf import EPSFBuilder, extract_stars hdu = load_simulated_hst_star_image() data = hdu.data data += make_noise_image(data.shape, distribution='gaussian', mean=10.0, stddev=5.0, seed=123) peaks_tbl = find_peaks(data, threshold=500.0) size = 25 hsize = (size - 1) / 2 x = peaks_tbl['x_peak'] y = peaks_tbl['y_peak'] mask = ((x > hsize) & (x < (data.shape[1] - 1 - hsize)) & (y > hsize) & (y < (data.shape[0] - 1 - hsize))) stars_tbl = Table() stars_tbl['x'] = x[mask] stars_tbl['y'] = y[mask] mean_val, median_val, std_val = sigma_clipped_stats(data, sigma=2.0) data -= median_val nddata = NDData(data=data) stars = extract_stars(nddata, stars_tbl, size=25) epsf_builder = EPSFBuilder(oversampling=4, maxiters=3, progress_bar=False) epsf, fitted_stars = epsf_builder(stars) norm = simple_norm(epsf.data, 'log', percent=99.0) plt.imshow(epsf.data, norm=norm, origin='lower', cmap='viridis') plt.colorbar() The :class:`~photutils.psf.EPSFModel` object is a subclass of :class:`~photutils.psf.FittableImageModel`, thus it can be used as a PSF model for the :ref:`PSF-fitting machinery in Photutils ` (i.e., `~photutils.psf.PSFPhotometry` or `~photutils.psf.IterativePSFPhotometry`). ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/user_guide/geometry.rst0000644000175100001660000000071614755160622020635 0ustar00runnerdockerGeometry 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` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/user_guide/grouping.rst0000644000175100001660000001564314755160622020641 0ustar00runnerdocker.. _psf-grouping: Source Grouping Algorithms ========================== Introduction ------------ In Point Spread Function (PSF) photometry, the PSF model fit for a given star can be affected by the presence of the profile of neighboring stars. In this case, a grouping algorithm can be used to combine neighboring stars into groups that can be fit simultaneously. The goal is to separate the stars into groups such that the profile of each star in the group does not extend into the fitting region of a star in another group. Creating groups reduces the number of stars that need to be fit simultaneously, which can be computationally expensive. Simultaneous fitting of all stars in an image is generally not feasible, especially for crowded fields. Stetson (`1987, PASP 99, 191 `_), provided a simple grouping algorithm to decide whether the profile of a given star extends into the fitting region of any other star. The paper defines this in terms of a "critical separation" parameter, which is defined as the minimal distance that any two stars must be separated by in order to be in different groups. The critical separation is generally defined as a multiple of the stellar full width at half maximum (FWHM). Getting Started --------------- Photutils provides the :class:`~photutils.psf.SourceGrouper` class to group stars. The groups are formed using hierarchical agglomerative clustering with a distance criterion, calling the `scipy.cluster.hierarchy.fclusterdata` function. To group stars during PSF fitting, typically one would simply pass an instance of the :class:`~photutils.psf.SourceGrouper` class with a defined minimum separation to the PSF photometry classes. Here, we will demonstrate how to use the :class:`~photutils.psf.SourceGrouper` class separately to group stars in a simulated image. First, let's create a simulated image containing 2D Gaussian sources using `~photutils.psf.make_psf_model_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) Let's display the image: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> plt.figure(figsize=(8, 8)) >>> plt.imshow(data, origin='lower', interpolation='nearest') .. plot:: import matplotlib.pyplot as plt from photutils.psf import 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) plt.figure(figsize=(8, 8)) plt.imshow(data, origin='lower', interpolation='nearest') plt.show() The ``make_psf_model_image`` function returns the simulated image (``data``) and a table of the star positions and fluxes (``stars``). The star positions are stored in the ``x_0`` and ``y_0`` columns of the table. Now, let's find the stellar groups. We start by creating a `~photutils.psf.SourceGrouper` object. Here we set the ``min_separation`` parameter ``2.5 * fwhm``, where the ``fwhm`` is taken from the 2D Gaussian PSF model used to generate the stars. In general, one will need to measure the FWHM of the stellar profiles:: >>> from photutils.psf import SourceGrouper >>> fwhm = 4.7 >>> min_separation = 2.5 * fwhm >>> grouper = SourceGrouper(min_separation) We then call the class instance on arrays of the star ``(x, y)`` positions. Here will use the known positions of the stars when we generated the image. In general, one can use a star finder (:ref:`source_detection`) to find the sources:: >>> import numpy as np >>> x = np.array(stars['x_0']) >>> y = np.array(stars['y_0']) >>> groups = grouper(x, y) The ``groups`` output is an array of integers (ordered the same as the ``(x, y)`` inputs) containing the group indices. Stars with the same group index are in the same group. The grouping algorithm separated the 100 stars into 65 distinct groups: .. doctest-skip:: >>> print(max(groups)) 65 For example, to find the positions of the stars in group 3:: >>> mask = groups == 3 >>> x[mask], y[mask] (array([60.32708921, 58.73063714]), array([147.24184586, 158.0612346 ])) When performing PSF photometry, the group indices can be included in the ``init_params`` table when calling the PSF photometry classes. These group indices would override the input `~photutils.psf.SourceGrouper` instance. Finally, let's plot a circular aperture around each star, where stars in the same group have the same aperture color: .. doctest-skip:: >>> import numpy as np >>> from photutils.aperture import CircularAperture >>> from photutils.utils import make_random_cmap >>> plt.imshow(data, origin='lower', interpolation='nearest', ... cmap='Greys_r') >>> cmap = make_random_cmap(seed=123) >>> for i in np.arange(1, max(groups) + 1): >>> mask = groups == i >>> xypos = zip(x[mask], y[mask]) >>> ap = CircularAperture(xypos, r=fwhm) >>> ap.plot(color=cmap.colors[i], lw=2) >>> plt.show() .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.aperture import CircularAperture from photutils.psf import (CircularGaussianPRF, SourceGrouper, make_psf_model_image) from photutils.utils import make_random_cmap 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) min_separation = 2.5 * fwhm grouper = SourceGrouper(min_separation) x = np.array(stars['x_0']) y = np.array(stars['y_0']) groups = grouper(x, y) plt.figure(figsize=(8, 8)) plt.imshow(data, origin='lower', interpolation='nearest', cmap='Greys_r') cmap = make_random_cmap(seed=123) for i in np.arange(1, max(groups) + 1): mask = groups == i xypos = zip(x[mask], y[mask]) ap = CircularAperture(xypos, r=fwhm) ap.plot(color=cmap.colors[i], lw=2) plt.show() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/user_guide/index.rst0000644000175100001660000000250714755160622020111 0ustar00runnerdocker.. _user_guide: ********** User Guide ********** Photutils is a Python library that provides commonly-used tools and key functionality for detecting and performing photometry of astronomical sources. Photutils is organized into subpackages covering different topics, which are listed below. Backgrounds ----------- .. toctree:: :maxdepth: 1 background.rst Centroids --------- .. toctree:: :maxdepth: 1 centroids.rst Source Detection ---------------- .. toctree:: :maxdepth: 1 detection.rst General Source Detection and Extraction (photutils.segmentation) Segmentation and Source Measurements ------------------------------------ .. toctree:: :maxdepth: 1 segmentation.rst morphology.rst Aperture Photometry ------------------- .. toctree:: :maxdepth: 1 aperture.rst PSF Photometry and Tools ------------------------ .. toctree:: :maxdepth: 1 psf.rst epsf.rst grouping.rst psf_matching.rst Radial Profiles --------------- .. toctree:: :maxdepth: 1 profiles.rst Elliptical Isophotes -------------------- .. toctree:: :maxdepth: 1 isophote.rst Datasets and Simulation ----------------------- .. toctree:: :maxdepth: 1 datasets.rst Utilities --------- .. toctree:: :maxdepth: 1 utils.rst geometry.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/user_guide/isophote.rst0000644000175100001660000002427614755160622020643 0ustar00runnerdockerElliptical Isophote Analysis (`photutils.isophote`) =================================================== Introduction ------------ The `~photutils.isophote` package provides tools to fit elliptical isophotes to a galaxy image. The isophotes in the image are measured using an iterative method described by `Jedrzejewski (1987; MNRAS 226, 747) `_. See the documentation of the :class:`~photutils.isophote.Ellipse` class for details about the algorithm. Please also see the :ref:`isophote-faq`. Getting Started --------------- For this example, let's create a simple simulated galaxy image:: >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from photutils.datasets import make_noise_image >>> g = Gaussian2D(100.0, 75, 75, 20, 12, theta=40.0 * np.pi / 180.0) >>> ny = nx = 150 >>> y, x = np.mgrid[0:ny, 0:nx] >>> noise = make_noise_image((ny, nx), distribution='gaussian', mean=0.0, ... stddev=2.0, seed=1234) >>> data = g(x, y) + noise .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.datasets import make_noise_image g = Gaussian2D(100.0, 75, 75, 20, 12, theta=40.0 * np.pi / 180.0) ny = nx = 150 y, x = np.mgrid[0:ny, 0:nx] noise = make_noise_image((ny, nx), distribution='gaussian', mean=0.0, stddev=2.0, seed=1234) data = g(x, y) + noise plt.imshow(data, origin='lower') We must provide the elliptical isophote fitter with an initial ellipse to be fitted. This ellipse geometry is defined with the `~photutils.isophote.EllipseGeometry` class. Here we'll define an initial ellipse whose position angle is offset from the data:: >>> from photutils.isophote import EllipseGeometry >>> geometry = EllipseGeometry(x0=75, y0=75, sma=20, eps=0.5, ... pa=20.0 * np.pi / 180.0) Let's show this initial ellipse guess: .. doctest-skip:: >>> import matplotlib.pyplot as plt >>> from photutils.aperture import EllipticalAperture >>> aper = EllipticalAperture((geometry.x0, geometry.y0), geometry.sma, ... geometry.sma * (1 - geometry.eps), ... geometry.pa) >>> plt.imshow(data, origin='lower') >>> aper.plot(color='white') .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.aperture import EllipticalAperture from photutils.datasets import make_noise_image from photutils.isophote import EllipseGeometry g = Gaussian2D(100.0, 75, 75, 20, 12, theta=40.0 * np.pi / 180.0) ny = nx = 150 y, x = np.mgrid[0:ny, 0:nx] noise = make_noise_image((ny, nx), distribution='gaussian', mean=0.0, stddev=2.0, seed=1234) data = g(x, y) + noise geometry = EllipseGeometry(x0=75, y0=75, sma=20, eps=0.5, pa=20.0 * np.pi / 180.0) aper = EllipticalAperture((geometry.x0, geometry.y0), geometry.sma, geometry.sma * (1 - geometry.eps), geometry.pa) plt.imshow(data, origin='lower') aper.plot(color='white') Next, we create an instance of the `~photutils.isophote.Ellipse` class, inputting the data to be fitted and the initial ellipse geometry object:: >>> from photutils.isophote import Ellipse >>> ellipse = Ellipse(data, geometry) To perform the elliptical isophote fit, we run the :meth:`~photutils.isophote.Ellipse.fit_image` method:: >>> isolist = ellipse.fit_image() The result is a list of isophotes as an `~photutils.isophote.IsophoteList` object, whose attributes are the fit values for each `~photutils.isophote.Isophote` sorted by the semimajor axis length. Let's print the fit position angles (radians):: >>> print(isolist.pa) # doctest: +SKIP [ 0. 0.16838914 0.18453378 0.20310945 0.22534975 0.25007781 0.28377499 0.32494582 0.38589202 0.40480013 0.39527698 0.38448771 0.40207495 0.40207495 0.28201524 0.28201524 0.19889817 0.1364335 0.1364335 0.13405719 0.17848892 0.25687327 0.35750355 0.64882699 0.72489435 0.91472008 0.94219702 0.87393299 0.82572916 0.7886367 0.75523282 0.7125274 0.70481612 0.7120097 0.71250791 0.69707669 0.7004807 0.70709823 0.69808124 0.68621341 0.69437566 0.70548293 0.70427021 0.69978326 0.70410887 0.69532744 0.69440413 0.70062534 0.68614488 0.7177538 0.7177538 0.7029571 0.7029571 0.7029571 ] We can also show the isophote values as a table, which is again sorted by the semimajor axis length (``sma``):: >>> print(isolist.to_table()) # doctest: +SKIP sma intens intens_err ... flag niter stop_code ... -------------- --------------- --------------- ... ---- ----- --------- 0.0 102.237692914 0.0 ... 0 0 0 0.534697261283 101.212218041 0.0280377938856 ... 0 10 0 0.588166987411 101.095404456 0.027821598428 ... 0 10 0 0.646983686152 100.971770355 0.0272405762608 ... 0 10 0 0.711682054767 100.842254551 0.0262991125932 ... 0 10 0 ... ... ... ... ... ... ... 51.874849202 3.44800874483 0.0881592058138 ... 0 50 2 57.0623341222 1.64031530995 0.0913122295433 ... 0 50 2 62.7685675344 0.692631010404 0.0786846787635 ... 0 32 0 69.0454242879 0.294659388337 0.0681758007533 ... 0 8 5 75.9499667166 0.0534892334515 0.0692483210903 ... 0 2 5 Length = 54 rows Let's plot the ellipticity, position angle, and the center x and y position as a function of the semimajor axis length: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.datasets import make_noise_image from photutils.isophote import Ellipse, EllipseGeometry g = Gaussian2D(100.0, 75, 75, 20, 12, theta=40.0 * np.pi / 180.0) ny = nx = 150 y, x = np.mgrid[0:ny, 0:nx] noise = make_noise_image((ny, nx), distribution='gaussian', mean=0.0, stddev=2.0, seed=1234) data = g(x, y) + noise geometry = EllipseGeometry(x0=75, y0=75, sma=20, eps=0.5, pa=20.0 * np.pi / 180.0) ellipse = Ellipse(data, geometry) isolist = ellipse.fit_image() plt.figure(figsize=(8, 8)) plt.subplots_adjust(hspace=0.35, wspace=0.35) plt.subplot(2, 2, 1) plt.errorbar(isolist.sma, isolist.eps, yerr=isolist.ellip_err, fmt='o', markersize=4) plt.xlabel('Semimajor Axis Length (pix)') plt.ylabel('Ellipticity') plt.subplot(2, 2, 2) plt.errorbar(isolist.sma, isolist.pa / np.pi * 180.0, yerr=isolist.pa_err / np.pi * 80.0, fmt='o', markersize=4) plt.xlabel('Semimajor Axis Length (pix)') plt.ylabel('PA (deg)') plt.subplot(2, 2, 3) plt.errorbar(isolist.sma, isolist.x0, yerr=isolist.x0_err, fmt='o', markersize=4) plt.xlabel('Semimajor Axis Length (pix)') plt.ylabel('x0') plt.subplot(2, 2, 4) plt.errorbar(isolist.sma, isolist.y0, yerr=isolist.y0_err, fmt='o', markersize=4) plt.xlabel('Semimajor Axis Length (pix)') plt.ylabel('y0') We can build an elliptical model image from the `~photutils.isophote.IsophoteList` object using the :func:`~photutils.isophote.build_ellipse_model` function:: >>> from photutils.isophote import build_ellipse_model >>> model_image = build_ellipse_model(data.shape, isolist) >>> residual = data - model_image Finally, let's plot the original data, overplotted with some isophotes, the elliptical model image, and the residual image: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.datasets import make_noise_image from photutils.isophote import (Ellipse, EllipseGeometry, build_ellipse_model) g = Gaussian2D(100.0, 75, 75, 20, 12, theta=40.0 * np.pi / 180.0) ny = nx = 150 y, x = np.mgrid[0:ny, 0:nx] noise = make_noise_image((ny, nx), distribution='gaussian', mean=0.0, stddev=2.0, seed=1234) data = g(x, y) + noise geometry = EllipseGeometry(x0=75, y0=75, sma=20, eps=0.5, pa=20.0 * np.pi / 180.0) ellipse = Ellipse(data, geometry) isolist = ellipse.fit_image() model_image = build_ellipse_model(data.shape, isolist) residual = data - model_image fig, (ax1, ax2, ax3) = plt.subplots(figsize=(14, 5), nrows=1, ncols=3) fig.subplots_adjust(left=0.04, right=0.98, bottom=0.02, top=0.98) ax1.imshow(data, origin='lower') ax1.set_title('Data') smas = np.linspace(10, 50, 5) for sma in smas: iso = isolist.get_closest(sma) x, y, = iso.sampled_coordinates() ax1.plot(x, y, color='white') ax2.imshow(model_image, origin='lower') ax2.set_title('Ellipse Model') ax3.imshow(residual, origin='lower') ax3.set_title('Residual') Additional Example Notebooks (online) ------------------------------------- Additional example notebooks showing examples with real data and advanced usage are available online: * `Basic example of the Ellipse fitting tool `_ * `Running Ellipse with sigma-clipping `_ * `Building an image model from results obtained by Ellipse fitting `_ * `Advanced Ellipse example: multi-band photometry and masked arrays `_ API Reference ------------- :doc:`../reference/isophote_api` .. toctree:: :hidden: isophote_faq.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/user_guide/isophote_faq.rst0000644000175100001660000002250114755160622021457 0ustar00runnerdocker.. _isophote-faq: Isophote Frequently Asked Questions ----------------------------------- .. _harmonic_ampl: 1. What are the basic equations relating harmonic amplitudes to geometrical parameter updates? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The basic elliptical isophote fitting algorithm, as described in `Jedrzejewski (1987; MNRAS 226, 747) `_, computes corrections for the current ellipse's geometrical parameters by essentially "projecting" the fitted harmonic amplitudes onto the image plane: .. math:: {\delta}_{X0} = \frac {-B_{1}} {I'} .. math:: {\delta}_{Y0} = \frac {-A_{1} (1 - {\epsilon})} {I'} .. math:: {\delta}_{\epsilon} = \frac {-2 B_{2} (1 - {\epsilon})} {I' a_0} .. math:: {\delta}_{\Theta} = \frac {2 A_{2} (1 - {\epsilon})} {I' a_0 [(1 - {\epsilon}) ^ 2 - 1]} where :math:`\epsilon` is the ellipticity, :math:`\Theta` is the position angle, :math:`A_i` and :math:`B_i` are the harmonic coefficients, and :math:`I'` is the derivative of the intensity along the major axis direction evaluated at a semimajor axis length of :math:`a_0`. 2. Why use "ellipticity" instead of the canonical ellipse eccentricity? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The main reason is that ellipticity, defined as .. math:: \epsilon = 1 - \frac{b}{a} better relates with the visual "flattening" of an ellipse. By looking at a flattened circle it is easy to guess its ellipticity, as say 0.1. The same ellipse has an eccentricity of 0.44, which is not obvious from visual inspection. The quantities relate as .. math:: Ecc = \sqrt{1 - (1 - {\epsilon})^2} 3. How is the radial gradient estimated? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The radial intensity gradient is the most critical quantity computed by the fitting algorithm. As can be seen from the above formulae, small :math:`I'` values lead to large values for the correction terms. Thus, :math:`I'` errors may lead to large fluctuations in these terms, when :math:`I'` itself is small. This usually happens at the fainter, outer regions of galaxy images. `Busko (1996; ASPC 101, 139) `_ found by numerical experiments that the precision to which a given ellipse can be fitted is related to the relative error in the local radial gradient. Because of the gradient's critical role, the algorithm has a number of features to allow its estimation even under difficult conditions. The default gradient computation, the one used by the algorithm when it first starts to fit a new isophote, is based on the extraction of two intensity samples: #1 at the current ellipse position, and #2 at a similar ellipse with a 10% larger semimajor axis. If the gradient so estimated is not meaningful, the algorithm extracts another #2 sample, this time using a 20% larger radius. In this context, a meaningful gradient means "shallower", but still close to within a factor 3 from the previous isophote's gradient estimate. If still no meaningful gradient can be measured, the algorithm uses the value measured at the last fitted isophote, but decreased (in absolute value) by a factor 0.8. This factor is roughly what is expected from semimajor-axis geometrical-sampling steps of 10 - 20% and a deVaucouleurs law or an exponential disk in its inner region (r <~ 5 req). When using the last isophote's gradient as estimator for the current one, the current gradient error cannot be computed and is set to `None`. As a last resort, if no previous gradient estimate is available, the algorithm just guesses the current value by setting it to be (minus) 10% of the mean intensity at sample #1. This case usually happens only at the first isophote fitted by the algorithm. The use of approximate gradient estimators may seem in contradiction with the fact that isophote fitting errors depend on gradient error, as well as with the fact that the algorithm itself is so sensitive to the gradient value. The rationale behind the use of approximate estimators, however, is based on the fact that the gradient value is used only to compute increments, not the ellipse parameters themselves. Approximate estimators are useful along the first steps in the iteration sequence, in particular when local image contamination (stars, defects, etc.) might make it difficult to find the correct path towards the solution. However, if the gradient is still not well determined at convergence, the subsequent error computations, and the algorithm's behavior from that point on, will take the fact into account properly. For instance, the 3rd and 4th harmonic amplitude errors depend on the gradient relative error, and if this is not computable at the current isophote, the algorithm uses a reasonable estimate (80% of the value at the last successful isophote) in order to generate sensible estimates for those harmonic errors. 4. How are the errors estimated? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Most parameters computed directly at each isophote have their errors computed by standard error propagation. Errors in the ellipse geometry parameters, on the other hand, cannot be estimated in the same way, since these parameters are not computed directly but result from a number of updates from a starting guess value. An error analysis based on numerical experiments (`Busko 1996; ASPC 101, 139 `_) showed that the best error estimators for these geometrical parameters can be found by simply "projecting" the harmonic amplitude errors that come from the least-squares covariance matrix by the same formulae in :ref:`Question 1 ` above used to "project" the associated parameter updates. In other words, errors for the ellipse center, ellipticity, and position angle are computed by the same formulae as in :ref:`Question 1 `, but replacing the least-squares amplitudes by their errors. This is empirical and difficult to justify in terms of any theoretical error analysis, but it produces sensible error estimators in practice. 5. How is the image sampled? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ When sampling is done using elliptical sectors (mean or median modes), the algorithm described in `Jedrzejewski (1987; MNRAS 226, 747) `_ uses an elaborate, high-precision scheme to take into account partial pixels that lie along elliptical sector boundaries. In the current implementation of the `~photutils.isophote.Ellipse` algorithm, this method was not implemented. Instead, pixels at sector boundaries are either fully included or discarded, depending on the precise position of their centers in relation to the elliptical geometric locus corresponding to the current ellipse. This design decision is based on two arguments: (i) it would be difficult to include partial pixels in median computation, and (ii) speed. Even when the chosen integration mode is not bilinear, the sampling algorithm resorts to it in case the number of sampled pixels inside any given sector is less than 5. It was found that bilinear mode gives smoother samples in those cases. Tests performed with artificial images showed that cosmic rays and defective pixels can be very effectively removed from the fit by a combination of median sampling and sigma clipping. 6. How reliable are the fluxes computed by the `~photutils.isophote.Ellipse` algorithm? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The integrated fluxes and areas computed by `~photutils.isophote.Ellipse` were checked against results produced by the IRAF ``noao.digiphot.apphot`` tasks ``phot`` and ``polyphot``, using artificial images. Quantities computed by `~photutils.isophote.Ellipse` match the reference ones within < 0.1% in all tested cases. 7. How does the object centerer work? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The `~photutils.isophote.EllipseGeometry` class has a :meth:`~photutils.isophote.EllipseGeometry.find_center` method that runs an "object locator" around the input object coordinates. This routine performs a scan over a 10x10 pixel window centered on the input object coordinates. At each scan position, it extracts two concentric, roughly circular samples with radii 4 and 8 pixels. It then computes a signal-to-noise-like criterion using the intensity averages and standard deviations at each annulus: .. math:: c = \frac{f_{1} - f_{2}}{{\sqrt{\sigma_{1}^{2} + \sigma_{2}^{2}}}} and locates the pixel inside the scanned window where this criterion is a maximum. If the criterion so computed exceeds a given threshold, it assumes that a suitable object was detected at that position. The default threshold value is set to 0.1. This value and the annuli and window sizes currently used were found by trial and error using a number of both artificial and real galaxy images. It was found that very flattened galaxy images (ellipticity ~ 0.7) cannot be detected by such a simple algorithm. By increasing the threshold value the object locator becomes stricter, in the sense that it will not detect faint objects. To turn off the object locator, set the threshold to a value >> 1 in `~photutils.isophote.Ellipse`. This will prevent it from modifying whatever values for the center coordinates were given to the `~photutils.isophote.Ellipse` algorithm. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/user_guide/morphology.rst0000644000175100001660000001234014755160622021175 0ustar00runnerdockerMorphological Properties (`photutils.morphology`) ================================================= Introduction ------------ The :func:`~photutils.morphology.data_properties` function can be used to calculate the basic morphological properties (e.g., elliptical shape properties) of a single source in a cutout image. :func:`~photutils.morphology.data_properties` returns a :class:`~photutils.segmentation.SourceCatalog` object. Please see :class:`~photutils.segmentation.SourceCatalog` for the list of the many properties that are calculated. The `photutils.morphology` subpackage also includes the :func:`~photutils.morphology.gini` function, which calculates the Gini coefficient of a source in an image. If you have a segmentation image, the :class:`~photutils.segmentation.SourceCatalog` class can be used to calculate the properties for all (or a specified subset) of the segmented sources. Please see :ref:`Source Photometry and Properties from Image Segmentation ` for more details. Getting Started --------------- Let's extract a single object from a synthetic dataset and find calculate its morphological properties. For this example, we will subtract the background using simple sigma-clipped statistics. First, we create the source image and subtract its background:: >>> from astropy.stats import sigma_clipped_stats >>> from photutils.datasets import make_4gaussians_image >>> data = make_4gaussians_image()[40:80, 75:105] >>> mean, median, std = sigma_clipped_stats(data, sigma=3.0) >>> data -= median # subtract background Then, calculate its properties:: >>> from photutils.morphology import data_properties >>> mask = data < 50 >>> cat = data_properties(data, mask=mask) >>> columns = ['label', 'xcentroid', 'ycentroid', 'semimajor_sigma', ... 'semiminor_sigma', 'orientation'] >>> tbl = cat.to_table(columns=columns) >>> tbl['xcentroid'].info.format = '.10f' # optional format >>> tbl['ycentroid'].info.format = '.10f' >>> tbl['semiminor_sigma'].info.format = '.10f' >>> tbl['orientation'].info.format = '.10f' >>> print(tbl) label xcentroid ycentroid ... semiminor_sigma orientation ... pix deg ----- ------------- ------------- ... --------------- ------------- 1 15.0203353055 20.0876025118 ... 3.2260911267 59.6896286141 Now let's use the measured morphological properties to define an approximate isophotal ellipse for the source: .. doctest-skip:: >>> import astropy.units as u >>> from photutils.aperture import EllipticalAperture >>> xypos = (cat.xcentroid, cat.ycentroid) >>> r = 2.5 # approximate isophotal extent >>> a = cat.semimajor_sigma.value * r >>> b = cat.semiminor_sigma.value * r >>> theta = cat.orientation.to(u.rad).value >>> apertures = EllipticalAperture(xypos, a, b, theta=theta) >>> plt.imshow(data, origin='lower', cmap='viridis', ... interpolation='nearest') >>> apertures.plot(color='C3') .. plot:: import astropy.units as u import matplotlib.pyplot as plt import numpy as np from photutils.aperture import EllipticalAperture from photutils.datasets import make_4gaussians_image from photutils.morphology import data_properties slc = np.s_[40:80, 75:105] data = make_4gaussians_image()[slc] # extract single object mask = data < 50 cat = data_properties(data, mask=mask) columns = ['label', 'xcentroid', 'ycentroid', 'semimajor_sigma', 'semiminor_sigma', 'orientation'] tbl = cat.to_table(columns=columns) r = 2.5 # approximate isophotal extent xypos = (cat.xcentroid, cat.ycentroid) a = cat.semimajor_sigma.value * r b = cat.semiminor_sigma.value * r theta = cat.orientation.to(u.rad).value apertures = EllipticalAperture(xypos, a, b, theta=theta) fig, ax = plt.subplots(1, 1, figsize=(8, 8)) ax.imshow(data, origin='lower', interpolation='nearest') 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.xcentroid, cat.ycentroid, 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.xcentroid, cat.ycentroid, dx_minor, dy_minor, color=color, length_includes_head=True, width=width) Gini Coefficient ---------------- 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 :func:`~photutils.morphology.gini` function calculates the Gini coefficient of a single source using the values in a cutout image. An optional boolean mask can be used to exclude pixels from the calculation. Let's calculate the Gini coefficient of the source in the above example:: >>> from photutils.morphology import gini >>> g = gini(data, mask=mask) >>> print(g) 0.21943786993407582 API Reference ------------- :doc:`../reference/morphology_api` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/user_guide/profiles.rst0000644000175100001660000004745314755160622020636 0ustar00runnerdocker.. _profiles: Radial Profiles (`photutils.profiles`) ====================================== Introduction ------------ `photutils.profiles` provides tools to calculate radial profiles and curves of growth using concentric circular apertures. Preliminaries ------------- Let’s start by making a synthetic image of a single source. Note that there is no background in this image. One should background-subtract the data before creating a radial profile or curve of growth. >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from photutils.datasets import make_noise_image >>> gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) >>> yy, xx = np.mgrid[0:100, 0:100] >>> data = gmodel(xx, yy) >>> bkg_sig = 2.4 >>> noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) >>> 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.4 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) 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) Creating a Radial Profile ------------------------- First, we'll use the `~photutils.centroids.centroid_quadratic` function to find the source centroid from the simulated image defined above:: >>> from photutils.centroids import centroid_quadratic >>> xycen = centroid_quadratic(data, xpeak=48, ypeak=52) >>> print(xycen) # doctest: +FLOAT_CMP [47.61226319 52.04668132] We'll use this centroid position as the center of our radial profile. We create a radial profile using the `~photutils.profiles.RadialProfile` class. The radial bins are defined by inputing a 1D array of radii that represent the radial *edges* of circular annulus apertures. The radial spacing does not need to be constant. The input ``error`` array is the uncertainty in the data values. The input ``mask`` array is a boolean mask with the same shape as the data, where a `True` value indicates a masked pixel:: >>> from photutils.profiles import RadialProfile >>> edge_radii = np.arange(25) >>> rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) The output `~photutils.profiles.RadialProfile.radius` attribute values are defined as the arithmetic means of the input radial-bins edges (``radii``). Note this is different from the input ``radii``, which represents the radial bin edges:: >>> print(rp.radii) # doctest: +FLOAT_CMP [ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24] >>> print(rp.radius) # doctest: +FLOAT_CMP [ 0.5 1.5 2.5 3.5 4.5 5.5 6.5 7.5 8.5 9.5 10.5 11.5 12.5 13.5 14.5 15.5 16.5 17.5 18.5 19.5 20.5 21.5 22.5 23.5] The `~photutils.profiles.RadialProfile.profile` and `~photutils.profiles.RadialProfile.profile_error` attributes contain the output 1D `~numpy.ndarray` objects containing the radial profile and propagated errors:: >>> print(rp.profile) # doctest: +FLOAT_CMP [ 4.15632243e+01 3.93402079e+01 3.59845746e+01 3.15540506e+01 2.62300757e+01 2.07297033e+01 1.65106801e+01 1.19376723e+01 7.75743772e+00 5.56759777e+00 3.44112671e+00 1.91350281e+00 1.17092981e+00 4.22261078e-01 9.70256904e-01 4.16355795e-01 1.52328707e-02 -6.69985111e-02 4.15522650e-01 2.48494731e-01 4.03348112e-01 1.43482678e-01 -2.62777461e-01 7.30653622e-02] >>> print(rp.profile_error) # doctest: +FLOAT_CMP [1.354055 0.78176402 0.60555181 0.51178468 0.45135167 0.40826294 0.37554729 0.3496155 0.32840658 0.31064152 0.29547903 0.28233999 0.270811 0.26058801 0.2514417 0.24319546 0.23571072 0.22887707 0.22260527 0.21682233 0.21146786 0.20649145 0.2018506 0.19750922] 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 that can be used to plot the raw data profile. These methods return the radii and values of the data points within the maximum radius defined by the input radii. Let's plot the raw data profile along with the radial profile and its error bars: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.4 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) data += noise error = np.zeros_like(data) + bkg_sig # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) # plot the radial profile fig, ax = plt.subplots(figsize=(8, 6)) rp.plot(ax=ax, color='C0') rp.plot_error(ax=ax) ax.scatter(rp.data_radius, rp.data_profile, s=1, color='C1') Normalization ^^^^^^^^^^^^^ If desired, the radial profile can be normalized using the :meth:`~photutils.profiles.RadialProfile.normalize` method. By default (``method='max'``), the profile is normalized such that its maximum value is 1. Setting ``method='sum'`` can be used to normalize the profile such that its sum (integral) is 1:: >> rp.normalize(method='max') There is also a method to "unnormalize" the radial profile back to the original values prior to running any calls to the :meth:`~photutils.profiles.RadialProfile.normalize` method:: >> rp.unnormalize() Plotting ^^^^^^^^ There are also convenience methods to plot the radial profile and its error. These methods plot ``rp.radius`` versus ``rp.profile`` (with ``rp.profile_error`` as error bars). The ``label`` keyword can be used to set the plot label. .. doctest-skip:: >>> rp.plot(label='Radial Profile') >>> rp.plot_error() .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.4 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) data += noise error = np.zeros_like(data) + bkg_sig # find the source centroid xycen = centroid_quadratic(data, xpeak=47, ypeak=52) # create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) # plot the radial profile 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_quadratic from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.4 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) data += noise error = np.zeros_like(data) + bkg_sig # find the source centroid xycen = centroid_quadratic(data, xpeak=47, ypeak=52) # create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) norm = simple_norm(data, 'sqrt') fig, ax = plt.subplots(figsize=(5, 5)) ax.imshow(data, norm=norm) 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 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Now let's fit a 1D Gaussian to the radial profile and return the `~astropy.modeling.functional_models.Gaussian1D` model using the `~photutils.profiles.RadialProfile.gaussian_fit` attribute. The returned value is a 1D Gaussian model fit to the radial profile:: >>> rp.gaussian_fit # doctest: +FLOAT_CMP The FWHM of the fitted 1D Gaussian model is stored in the `~photutils.profiles.RadialProfile.gaussian_fwhm` attribute:: >>> print(rp.gaussian_fwhm) # doctest: +FLOAT_CMP 11.09260130738712 The 1D Gaussian model evaluated at the profile radius values is stored in the `~photutils.profiles.RadialProfile.gaussian_profile` attribute:: >>> print(rp.gaussian_profile) # doctest: +FLOAT_CMP [4.13154108e+01 3.94948235e+01 3.60907893e+01 3.15268576e+01 2.63264980e+01 2.10152035e+01 1.60362275e+01 1.16976580e+01 8.15687363e+00 5.43721678e+00 3.46463641e+00 2.11040974e+00 1.22886451e+00 6.84020824e-01 3.63967618e-01 1.85133184e-01 9.00189404e-02 4.18419219e-02 1.85916294e-02 7.89680446e-03 3.20636838e-03 1.24452479e-03 4.61765823e-04 1.63782737e-04] Finally, let's plot the fitted 1D Gaussian model for the class:`~photutils.profiles.RadialProfile` radial profile: .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.4 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) data += noise error = np.zeros_like(data) + bkg_sig # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) # plot the radial profile 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.legend() Creating a Curve of Growth -------------------------- Now let's create a curve of growth using the `~photutils.profiles.CurveOfGrowth` class. We use the simulated image defined above and the same source centroid. The curve of growth will be centered at our centroid position. It will be computed over the radial range given by the input ``radii`` array:: >>> from photutils.profiles import CurveOfGrowth >>> radii = np.arange(1, 26) >>> cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) Here, the `~photutils.profiles.CurveOfGrowth.radius` attribute values are identical to the input ``radii``. Because these values are the radii of the circular apertures used to measure the profile, they can be used directly to measure the encircled energy/flux at a given radius. In other words, they are the radial values that enclose the given flux:: >>> print(cog.radius) # doctest: +FLOAT_CMP [ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25] The `~photutils.profiles.CurveOfGrowth.profile` and `~photutils.profiles.CurveOfGrowth.profile_error` attributes contain output 1D `~numpy.ndarray` objects containing the curve-of-growth profile and propagated errors:: >>> print(cog.profile) # doctest: +FLOAT_CMP [ 130.57472018 501.34744442 1066.59182074 1760.50163608 2502.13955554 3218.50667597 3892.81448231 4455.36403436 4869.66609313 5201.99745378 5429.02043984 5567.28370644 5659.24831854 5695.06577065 5783.46217755 5824.01080702 5825.59003768 5818.22316662 5866.52307412 5896.96917375 5948.92254787 5968.30540534 5931.15611704 5941.94457249 5942.06535486] >>> print(cog.profile_error) # doctest: +FLOAT_CMP [ 4.25388924 8.50777848 12.76166773 17.01555697 21.26944621 25.52333545 29.7772247 34.03111394 38.28500318 42.53889242 46.79278166 51.04667091 55.30056015 59.55444939 63.80833863 68.06222787 72.31611712 76.57000636 80.8238956 85.07778484 89.33167409 93.58556333 97.83945257 102.09334181 106.34723105] Normalization ^^^^^^^^^^^^^ If desired, the curve-of-growth profile can be normalized using the :meth:`~photutils.profiles.CurveOfGrowth.normalize` method. By default (``method='max'``), the profile is normalized such that its maximum value is 1. Setting ``method='sum'`` can also be used to normalize the profile such that its sum (integral) is 1:: >> cog.normalize(method='max') There is also a method to "unnormalize" the radial profile back to the original values prior to running any calls to the :meth:`~photutils.profiles.CurveOfGrowth.normalize` method:: >> cog.unnormalize() 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. .. doctest-skip:: >>> rp.plot(label='Curve of Growth') >>> rp.plot_error() .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import CurveOfGrowth # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.4 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) data += noise error = np.zeros_like(data) + bkg_sig # find the source centroid xycen = centroid_quadratic(data, xpeak=47, ypeak=52) # create the radial profile radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) # plot the radial profile 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_quadratic from photutils.datasets import make_noise_image from photutils.profiles import CurveOfGrowth # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.4 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) data += noise error = np.zeros_like(data) + bkg_sig # find the source centroid xycen = centroid_quadratic(data, xpeak=47, ypeak=52) # create the radial profile radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) norm = simple_norm(data, 'sqrt') fig, ax = plt.subplots(figsize=(5, 5)) ax.imshow(data, norm=norm) 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 ^^^^^^^^^^^^^^^^ Often, one is interested in the encircled energy (or flux) within a given radius, where the encircled energy is generally expressed as a normalized value between 0 and 1. If the curve of growth is monotonically increasing and normalized such that its maximum value is 1 for an infinitely large radius, then the encircled energy is simply the value of the curve of growth at a given radius. To achieve this, one can input a normalized version of the ``data`` array (e.g., a normalized PSF) to the `~photutils.profiles.CurveOfGrowth` class. One can also use the :meth:`~photutils.profiles.CurveOfGrowth.normalize` method to normalize the curve of growth profile to be 1 at the largest input ``radii`` value. If the curve of growth is normalized, the encircled energy at a given radius is simply the value of the curve of growth at that radius. The `~photutils.profiles.CurveOfGrowth` class provides two convenience methods to calculate the encircled energy at a given radius (or radii) and the radius corresponding to the given encircled energy (or energies). These methods are :meth:`~photutils.profiles.CurveOfGrowth.calc_ee_at_radius` and :meth:`~photutils.profiles.CurveOfGrowth.calc_radius_at_ee`, respectively. They are implemented as interpolation functions using the calculated curve-of-growth profile. The accuracy of these methods is dependent on the quality of the curve-of-growth profile (e.g., it's better to have a curve-of-growth profile with high signal to noise and more radial bins). Also, if the curve-of-growth profile is not monotonically increasing, the interpolation may fail. Let's compute the encircled energy values at a few radii for the curve of growth we created above:: >>> cog.normalize(method='max') >>> ee_rads = np.array([5, 10, 15]) >>> ee_vals = cog.calc_ee_at_radius(ee_rads) # doctest: +FLOAT_CMP >>> ee_vals array([0.41923785, 0.87160376, 0.96902919]) >>> cog.calc_radius_at_ee(ee_vals) # doctest: +FLOAT_CMP array([ 5., 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_quadratic from photutils.datasets import make_noise_image from photutils.profiles import CurveOfGrowth # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.4 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) data += noise error = np.zeros_like(data) + bkg_sig # find the source centroid xycen = centroid_quadratic(data, xpeak=47, ypeak=52) # create the radial profile radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) cog.normalize(method='max') ee_rads = np.array([5, 10, 15]) ee_vals = cog.calc_ee_at_radius(ee_rads) # plot the radial profile fig, ax = plt.subplots(figsize=(8, 6)) cog.plot(ax=ax, label='Curve of Growth') cog.plot_error(ax=ax) ax.legend() xmin, xmax = ax.get_xlim() ymin, ymax = ax.get_ylim() ax.vlines(ee_rads, ymin, ee_vals, colors='C1', linestyles='dashed') ax.hlines(ee_vals, xmin, ee_rads, colors='C1', linestyles='dashed') ax.set_xlim(xmin, xmax) ax.set_ylim(ymin, ymax) for ee_rad, ee_val in zip(ee_rads, ee_vals): ax.text(ee_rad/2, ee_val, f'{ee_val:.2f}', ha='center', va='bottom') API Reference ------------- :doc:`../reference/profiles_api` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/user_guide/psf.rst0000644000175100001660000011063114755160622017570 0ustar00runnerdocker.. _psf-photometry: PSF Photometry (`photutils.psf`) ================================ The `photutils.psf` subpackage contains tools for model-fitting photometry, often called "PSF photometry". .. _psf-terminology: Terminology ----------- Different astronomy subfields use the terms "PSF", "PRF", or related terms in slightly varied ways, especially when colloquial usage is taken into account. The `photutils.psf` package aims to be internally consistent, following the definitions described here. We take the Point Spread Function (PSF), or instrumental Point Spread Function (iPSF), to be the infinite-resolution and infinite-signal-to-noise flux distribution from a point source on the detector, after passing through optics, dust, atmosphere, etc. By contrast, the function describing the responsivity variations across individual *pixels* is the pixel response function. The pixel response function is sometimes called the "PRF", but we do not use that acronym here to avoid confusion with the "Point Response Function" (see below). The convolution of the PSF and pixel response function, when discretized onto the detector (i.e., a rectilinear grid), is the effective PSF (ePSF) or Point Response Function (PRF). The PRF terminology is sometimes used to emphasize that the model function describes the response of the detector to a point source, rather than the intrinsic instrumental PSF (e.g., see the `Spitzer Space Telescope MOPEX documentation `_). In many cases the PSF/PRF/ePSF distinction is unimportant, and the PSF/PRF/ePSF is simply called the "PSF" model. However, the distinction can be critical when dealing carefully with undersampled data or detectors with significant intra-pixel sensitivity variations. For a more detailed description of this formalism, see `Anderson & King 2000 `_. In colloquial usage, "PSF photometry" sometimes refers to the more general task of model-fitting photometry with the effects of the PSF either implicitly or explicitly included in the models, regardless of exactly what kind of model is actually being fit. In the ``photutils.psf`` package, we use "PSF photometry" in this way, as a shorthand for the general approach. PSF Photometry Overview ----------------------- Photutils provides a modular set of tools to perform PSF photometry for different science cases. The tools are implemented as classes that perform various subtasks of PSF photometry. High-level classes are also provided to connect these pieces together. The two main PSF-photometry classes are `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry`. `~photutils.psf.PSFPhotometry` provides the framework for a flexible PSF photometry workflow that can find sources in an image, optionally group overlapping sources, fit the PSF model to the sources, and subtract the fit PSF models from the image. `~photutils.psf.IterativePSFPhotometry` is an iterative version of `~photutils.psf.PSFPhotometry` where new sources are detected in the residual image after the fit sources are subtracted. The iterative process can be useful for crowded fields where sources are blended. A ``mode`` keyword is provided to control the behavior of the iterative process, where either all sources or only the newly-detected sources are fit in subsequent iterations. The process repeats until no additional sources are detected or a maximum number of iterations has been reached. When used with the `~photutils.detection.DAOStarFinder`, `~photutils.psf.IterativePSFPhotometry` is essentially an implementation of the DAOPHOT algorithm described by Stetson in his `seminal paper `_ for crowded-field stellar photometry. The star-finding step is controlled by the ``finder`` keyword, where one inputs a callable function or class instance. Typically, this would be one of the star-detection classes implemented in the `photutils.detection` subpackage, e.g., `~photutils.detection.DAOStarFinder`, `~photutils.detection.IRAFStarFinder`, or `~photutils.detection.StarFinder`. After finding sources, one can optionally apply a clustering algorithm to group overlapping sources using the ``grouper`` keyword. Usually, groups are formed by a distance criterion, which is the case of the grouping algorithm proposed by Stetson. Stars that grouped are fit simultaneously. The reason behind the construction of groups and not fitting all stars simultaneously is illustrated as follows: imagine that one would like to fit 300 stars and the model for each star has three parameters to be fitted. If one constructs a single model to fit the 300 stars simultaneously, then the optimization algorithm will have to search for the solution in a 900-dimensional space, which is computationally expensive and error-prone. Having smaller groups of stars effectively reduces the dimension of the parameter space, which facilitates the optimization process. For more details see :ref:`psf-grouping`. The local background around each source can optionally be subtracted using the ``localbkg_estimator`` keyword. This keyword accepts a `~photutils.background.LocalBackground` instance that estimates the local statistics in a circular annulus aperture centered on each source. The size of the annulus and the statistic function can be configured in `~photutils.background.LocalBackground`. The next step is to fit the sources and/or groups. This task is performed using an astropy fitter, for example `~astropy.modeling.fitting.TRFLSQFitter`, input via the ``fitter`` keyword. The shape of the region to be fitted can be configured using the ``fit_shape`` parameter. In general, ``fit_shape`` should be set to a small size (e.g., (5, 5)) that covers the central star region with the highest flux signal-to-noise. The initial positions are derived from the ``finder`` algorithm. The initial flux values for the fit are derived from measuring the flux in a circular aperture with radius ``aperture_radius``. Alternatively, the initial positions and fluxes can be input in a table via the ``init_params`` keyword when calling the class. After sources are fitted, a model image of the fit sources or a residual image can be generated using the :meth:`~photutils.psf.PSFPhotometry.make_model_image` and :meth:`~photutils.psf.PSFPhotometry.make_residual_image` methods, respectively. For `~photutils.psf.IterativePSFPhotometry`, the above steps can be repeated until no additional sources are detected (or until a maximum number of iterations is reached). The `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry` classes provide the structure in which the PSF-fitting steps described above are performed, but all the stages can be turned on or off or replaced with different implementations as the user desires. This makes the tools very flexible. One can also bypass several of the steps by directly inputting to ``init_params`` an astropy table containing the initial parameters for the source centers, fluxes, group identifiers, and local backgrounds. This is also useful if one is interested in fitting only one or a few sources in an image. .. _psf-models: PSF Models ---------- As mentioned above, PSF photometry fundamentally involves fitting models to data. As such, the PSF model is a critical component of PSF photometry. For accurate results, both for photometry and astrometry, the PSF model should be a good representation of the actual data. The PSF model can be a simple analytic function, such as a 2D Gaussian or Moffat profile, or it can be a more complex model derived from a 2D PSF image, e.g., an effective PSF (ePSF). The PSF model can also encapsulate changes in the PSF across the detector, e.g., due to optical aberrations. For image-based PSF models, the PSF model is typically derived from observed data or from detailed optical modeling. The PSF model can be a single PSF model for the entire image or a grid of PSF models at fiducial detector positions. Image-based PSF models are also often oversampled with respect to the pixel grid to increase the accuracy of fitting the PSF model. The observatory that obtained the data may provide tools for creating PSF models for their data or an empirical library of PSF models based on previous observations. For example, the `Hubble Space Telescope `_ provides libraries of empirical PSF models for ACS and WFC3 (e.g., `WFC3 PSF Search `_). Similarly, the `James Webb Space Telescope `_ and the `Nancy Grace Roman Space Telescope `_ provide the `WebbPSF `_ Python software for creating PSF models. In particular, WebbPSF outputs gridded PSF models directly as Photutils `~photutils.psf.GriddedPSFModel` instances. If you cannot obtain a PSF model from an empirical library or observatory-provided tool, Photutils provides tools for creating an empirical PSF model from the data itself, provided you have a large number of isolated stars. Please see :ref:`build-epsf` for more information and an example. The `photutils.psf` subpackage provides several PSF models that can be used for PSF photometry. The PSF models are based on the :ref:`Astropy models and fitting ` framework. The PSF models are used as input (via the ``psf_model`` parameter) to the PSF photometry classes `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry`. The PSF models are fitted to the data using an Astropy fitter class. Typically, the model position (``x_0`` and ``y_0``) and flux (``flux``) parameters are varied during the fitting process. The PSF model can also include additional parameters, such as the full width at half maximum (FWHM) or sigma of a Gaussian PSF or the alpha and beta parameters of a Moffat PSF. By default, these additional parameters are "fixed" (i.e., not varied during the fitting process). The user can choose to also vary these parameters by setting the ``fixed`` attribute on the model parameter to `False`. The position and/or flux parameters can also be fixed during the fitting process if needed, e.g., for forced photometry (see :ref:`psf-forced-photometry`). Any of the model parameters can also be bounded during the fitting process (see :ref:`psf-bounded-parameters`). You can also create your own custom PSF model using the Astropy modeling framework. The PSF model must be a 2D model that is a subclass of `~astropy.modeling.Fittable2DModel`. It must have parameters called ``x_0``, ``y_0``, and ``flux``, specifying the central position and total integrated flux, and it should be normalized to unit flux. Analytic PSF Models ^^^^^^^^^^^^^^^^^^^ The `photutils.psf` subpackage provides the following analytic PSF models: - `~photutils.psf.GaussianPSF`: a general 2D Gaussian PSF model parameterized in terms of the position, total flux, and full width at half maximum (FWHM) along the x and y axes. Rotation can also be included. - `~photutils.psf.CircularGaussianPSF`: a circular 2D Gaussian PSF model parameterized in terms of the position, total flux, and FWHM. - `~photutils.psf.GaussianPRF`: a general 2D Gaussian PSF model parameterized in terms of the position, total flux, and FWHM along the x and y axes. Rotation can also be included. - `~photutils.psf.CircularGaussianPRF`: a circular 2D Gaussian PRF model parameterized in terms of the position, total flux, and FWHM. - `~photutils.psf.CircularGaussianSigmaPRF`: a circular 2D Gaussian PRF model parameterized in terms of the position, total flux, and sigma (standard deviation). - `~photutils.psf.MoffatPSF`: a 2D Moffat PSF model parameterized in terms of the position, total flux, :math:`\alpha`, and :math:`\beta` parameters. - `~photutils.psf.AiryDiskPSF`: a 2D Airy disk PSF model parameterized in terms of the position, total flux, and radius of the first dark ring. Note there are two types of defined models, PSF and PRF models. The PSF models are evaluated by sampling the analytic function at the input (x, y) coordinates. The PRF models are evaluated by integrating the analytic function over the pixel areas. If one needs a custom PRF model based on an analytical PSF model, an efficient option is to first discretize the model on a grid using :func:`~astropy.convolution.discretize_model` with the ``'oversample'`` or ``'integrate'`` mode. The resulting 2D image can then be used as the input to `~photutils.psf.ImagePSF` (see :ref:`psf-image_models` below) to create an ePSF model. Note that the non-circular Gaussian and Moffat models above have additional parameters beyond the standard PSF model parameters of position and flux (``x_0``, ``y_0``, and ``flux``). By default, these other parameters are "fixed" (i.e., not varied during the fitting process). The user can choose to also vary these parameters by setting the ``fixed`` attribute on the model parameter to `False`. Photutils also provides a convenience function called :func:`~photutils.psf.make_psf_model` that creates a PSF model from an Astropy fittable 2D model. However, it is recommended that one use the PSF models provided by `photutils.psf` as they are optimized for PSF photometry. If a custom PSF model is needed, one can be created using the Astropy modeling framework that will provide better performance than using :func:`~photutils.psf.make_psf_model`. .. _psf-image_models: Image-based PSF Models ^^^^^^^^^^^^^^^^^^^^^^ Image-based PSF models are typically derived from observed data or from detailed optical modeling. The PSF model can be a single PSF model for the entire image or a grid of PSF models at fiducial detector positions, which are then interpolated for specific locations. The model classes below provide the tools needed to perform PSF photometry within Photutils using the Astropy modeling and fitting framework. The user must provide the image-based PSF model as an input to these classes. The input image(s) can be oversampled to increase the accuracy of the PSF model. - `~photutils.psf.ImagePSF`: a general class for image-based PSF models that allows for intensity scaling and translations. - `~photutils.psf.GriddedPSFModel`: a PSF model that contains a grid of image-based ePSF models at fiducial detector positions. .. _psf-photometry-examples: PSF Photometry Examples ----------------------- Let's start with a simple example using simulated stars whose PSF is assumed to be Gaussian. We'll create a synthetic image using tools provided by the :ref:`photutils.datasets ` module:: >>> import numpy as np >>> from photutils.datasets import make_noise_image >>> from photutils.psf import CircularGaussianPRF, make_psf_model_image >>> psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) >>> psf_shape = (9, 9) >>> n_sources = 10 >>> shape = (101, 101) >>> data, true_params = make_psf_model_image(shape, psf_model, n_sources, ... model_shape=psf_shape, ... flux=(500, 700), ... min_separation=10, seed=0) >>> noise = make_noise_image(data.shape, mean=0, stddev=1, seed=0) >>> data += noise >>> error = np.abs(noise) Let's plot the image: .. plot:: import matplotlib.pyplot as plt from photutils.datasets import make_noise_image from photutils.psf import CircularGaussianPRF, make_psf_model_image psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) psf_shape = (9, 9) n_sources = 10 shape = (101, 101) data, true_params = make_psf_model_image(shape, psf_model, n_sources, model_shape=psf_shape, flux=(500, 700), min_separation=10, seed=0) noise = make_noise_image(data.shape, mean=0, stddev=1, seed=0) data += noise plt.imshow(data, origin='lower') plt.title('Simulated Data') plt.colorbar() Fitting multiple stars ^^^^^^^^^^^^^^^^^^^^^^ Now let's use `~photutils.psf.PSFPhotometry` to perform PSF photometry on the stars in this image. Note that the input image must be background-subtracted prior to using the photometry classes. See :ref:`background` for tools to subtract a global background from an image. This step is not needed for our synthetic image because it does not include background. We'll use the `~photutils.detection.DAOStarFinder` class for source detection. We'll estimate the initial fluxes of each source using a circular aperture with a radius 4 pixels. The central 5x5 pixel region of each star will be fit using an `~photutils.psf.CircularGaussianPRF` PSF model. First, let's create an instance of the `~photutils.psf.PSFPhotometry` class:: >>> from photutils.detection import DAOStarFinder >>> from photutils.psf import PSFPhotometry >>> psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) >>> fit_shape = (5, 5) >>> finder = DAOStarFinder(6.0, 2.0) >>> psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, ... aperture_radius=4) To perform the PSF fitting, we then call the class instance on the data array, and optionally an error and mask array. A `~astropy.nddata.NDData` object holding the data, error, and mask arrays can also be input into the ``data`` parameter. Note that all non-finite (e.g., NaN or inf) data values are automatically masked. Here we input the data and error arrays:: >>> phot = psfphot(data, error=error) A table of initial PSF model parameter values can also be input when calling the class instance. An example of that is shown later. Equivalently, one can input an `~astropy.nddata.NDData` object with any uncertainty object that can be converted to standard-deviation errors: .. doctest-skip:: >>> from astropy.nddata import NDData, StdDevUncertainty >>> uncertainty = StdDevUncertainty(error) >>> nddata = NDData(data, uncertainty=uncertainty) >>> phot2 = psfphot(nddata) The result is an astropy `~astropy.table.Table` with columns for the source and group identification numbers, the x, y, and flux initial, fit, and error values, local background, number of unmasked pixels fit, the group size, quality-of-fit metrics, and flags. See the `~photutils.psf.PSFPhotometry` documentation for descriptions of the output columns. The full table cannot be shown here as it has many columns, but let's print the source ID along with the fit x, y, and flux values:: >>> phot['x_fit'].info.format = '.4f' # optional format >>> phot['y_fit'].info.format = '.4f' >>> phot['flux_fit'].info.format = '.4f' >>> print(phot[('id', 'x_fit', 'y_fit', 'flux_fit')]) # doctest: +FLOAT_CMP id x_fit y_fit flux_fit --- ------- ------- -------- 1 54.5658 7.7644 514.0091 2 29.0865 25.6111 536.5793 3 79.6281 28.7487 618.7642 4 63.2340 48.6408 563.3437 5 88.8848 54.1202 619.8904 6 79.8763 61.1380 648.1658 7 90.9606 72.0861 601.8593 8 7.8038 78.5734 635.6317 9 5.5350 89.8870 539.6831 10 71.8414 90.5842 692.3373 Let's create the residual image:: >>> resid = psfphot.make_residual_image(data) and plot it: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import simple_norm from photutils.datasets import make_noise_image from photutils.detection import DAOStarFinder from photutils.psf import (CircularGaussianPRF, PSFPhotometry, make_psf_model_image) psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) psf_shape = (9, 9) n_sources = 10 shape = (101, 101) data, true_params = make_psf_model_image(shape, psf_model, n_sources, model_shape=psf_shape, flux=(500, 700), min_separation=10, seed=0) noise = make_noise_image(data.shape, mean=0, stddev=1, seed=0) data += noise error = np.abs(noise) psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) phot = psfphot(data, error=error) resid = psfphot.make_residual_image(data) fig, ax = plt.subplots(nrows=1, ncols=3, figsize=(15, 5)) norm = simple_norm(data, 'sqrt', percent=99) ax[0].imshow(data, origin='lower', norm=norm) ax[1].imshow(data - resid, origin='lower', norm=norm) im = ax[2].imshow(resid, origin='lower', norm=norm) ax[0].set_title('Data') ax[1].set_title('Model') ax[2].set_title('Residual Image') plt.tight_layout() The residual image looks like noise, indicating good fits to the sources. Further details about the PSF fitting can be obtained from attributes on the `~photutils.psf.PSFPhotometry` instance. For example, the results from the ``finder`` instance called during PSF fitting can be accessed using the ``finder_results`` attribute (the ``finder`` returns an astropy table):: >>> psfphot.finder_results['xcentroid'].info.format = '.4f' # optional format >>> psfphot.finder_results['ycentroid'].info.format = '.4f' # optional format >>> psfphot.finder_results['sharpness'].info.format = '.4f' # optional format >>> psfphot.finder_results['peak'].info.format = '.4f' >>> psfphot.finder_results['flux'].info.format = '.4f' >>> psfphot.finder_results['mag'].info.format = '.4f' >>> psfphot.finder_results['daofind_mag'].info.format = '.4f' >>> print(psfphot.finder_results) # doctest: +FLOAT_CMP id xcentroid ycentroid sharpness ... peak flux mag daofind_mag --- --------- --------- --------- ... ------- -------- ------- ----------- 1 54.5299 7.7460 0.6006 ... 53.5953 476.3221 -6.6948 -2.1093 2 29.0927 25.5992 0.5955 ... 57.1982 499.4443 -6.7462 -2.1958 3 79.6185 28.7515 0.5957 ... 65.7175 574.1382 -6.8975 -2.3401 4 63.2485 48.6134 0.5802 ... 58.3985 521.4656 -6.7931 -2.2209 5 88.8820 54.1311 0.5948 ... 69.1869 576.2842 -6.9016 -2.4379 6 79.8727 61.1208 0.6216 ... 74.0935 612.8353 -6.9684 -2.4799 7 90.9621 72.0803 0.6167 ... 68.4157 561.7163 -6.8738 -2.4035 8 7.7962 78.5465 0.5979 ... 66.2173 595.6881 -6.9375 -2.3167 9 5.5858 89.8664 0.5741 ... 54.3786 505.6093 -6.7595 -2.1188 10 71.8303 90.5624 0.6038 ... 73.5747 639.9299 -7.0153 -2.4516 The ``fit_info`` attribute contains a dictionary with detailed information returned from the ``fitter`` for each source:: >>> psfphot.fit_info.keys() dict_keys(['fit_infos', 'fit_error_indices']) The ``fit_error_indices`` key contains the indices of sources for which the fit reported warnings or errors. As an example, let's print the covariance matrix of the fit parameters for the first source (note that not all astropy fitters will return a covariance matrix): .. doctest-skip:: >>> psfphot.fit_info['fit_infos'][0]['param_cov'] # doctest: +FLOAT_CMP array([[ 7.27034774e-01, 8.86845334e-04, 3.98593038e-03], [ 8.86845334e-04, 2.92871525e-06, -6.36805464e-07], [ 3.98593038e-03, -6.36805464e-07, 4.29520185e-05]]) Fitting a single source ^^^^^^^^^^^^^^^^^^^^^^^ In some cases, one may want to fit only a single source (or few sources) in an image. We can do that by defining a table of the sources that we want to fit. For this example, let's fit the single star at ``(x, y) = (63, 49)``. We first define a table with this position and then pass that table into the ``init_params`` keyword when calling the PSF photometry class on the data:: >>> from astropy.table import QTable >>> init_params = QTable() >>> init_params['x'] = [63] >>> init_params['y'] = [49] >>> phot = psfphot(data, error=error, init_params=init_params) The PSF photometry class allows for flexible input column names using a heuristic to identify the x, y, and flux columns. See `~photutils.psf.PSFPhotometry` for more details. The output table contains only the fit results for the input source:: >>> phot['x_fit'].info.format = '.4f' # optional format >>> phot['y_fit'].info.format = '.4f' >>> phot['flux_fit'].info.format = '.4f' >>> print(phot[('id', 'x_fit', 'y_fit', 'flux_fit')]) # doctest: +FLOAT_CMP id x_fit y_fit flux_fit --- ------- ------- -------- 1 63.2340 48.6408 563.3426 Finally, let's show the residual image. The red circular aperture shows the location of the star that was fit and subtracted. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.table import QTable from astropy.visualization import simple_norm from photutils.aperture import CircularAperture from photutils.datasets import make_noise_image from photutils.detection import DAOStarFinder from photutils.psf import (CircularGaussianPRF, PSFPhotometry, make_psf_model_image) psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) psf_shape = (9, 9) n_sources = 10 shape = (101, 101) data, true_params = make_psf_model_image(shape, psf_model, n_sources, model_shape=psf_shape, flux=(500, 700), min_separation=10, seed=0) noise = make_noise_image(data.shape, mean=0, stddev=1, seed=0) data += noise error = np.abs(noise) psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) init_params = QTable() init_params['x'] = [63] init_params['y'] = [49] phot = psfphot(data, error=error, init_params=init_params) resid = psfphot.make_residual_image(data) aper = CircularAperture(zip(phot['x_fit'], phot['y_fit']), r=4) fig, ax = plt.subplots(nrows=1, ncols=3, figsize=(15, 5)) norm = simple_norm(data, 'sqrt', percent=99) ax[0].imshow(data, origin='lower', norm=norm) ax[1].imshow(data - resid, origin='lower', norm=norm) im = ax[2].imshow(resid, origin='lower', norm=norm) ax[0].set_title('Data') aper.plot(ax=ax[0], color='red') ax[1].set_title('Model') aper.plot(ax=ax[1], color='red') ax[2].set_title('Residual Image') aper.plot(ax=ax[2], color='red') plt.tight_layout() .. _psf-forced-photometry: Forced Photometry (Fixed Model Parameters) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ In general, the three parameters fit for each source are the x and y positions and the flux. However, the astropy modeling and fitting framework allows any of these parameters to be fixed during the fitting. Let's say you want to fix the (x, y) position for each source. You can do that by setting the ``fixed`` attribute on the model parameters:: >>> psf_model2 = CircularGaussianPRF(flux=1, fwhm=2.7) >>> psf_model2.x_0.fixed = True >>> psf_model2.y_0.fixed = True >>> psf_model2.fixed {'flux': False, 'x_0': True, 'y_0': True, 'fwhm': True} Now when the model is fit, the flux will be varied but, the (x, y) position will be fixed at its initial position for every source. Let's just fit a single source (defined in ``init_params``):: >>> psfphot = PSFPhotometry(psf_model2, fit_shape, finder=finder, ... aperture_radius=4) >>> phot = psfphot(data, error=error, init_params=init_params) The output table shows that the (x, y) position was unchanged, with the fit values being identical to the initial values. However, the flux was fit:: >>> phot['flux_init'].info.format = '.4f' # optional format >>> phot['flux_fit'].info.format = '.4f' >>> print(phot[('id', 'x_init', 'y_init', 'flux_init', 'x_fit', ... 'y_fit', 'flux_fit')]) # doctest: +FLOAT_CMP id x_init y_init flux_init x_fit y_fit flux_fit --- ------ ------ --------- ----- ----- -------- 1 63 49 556.5067 63.0 49.0 500.2997 .. _psf-bounded-parameters: Bounded Model Parameters ^^^^^^^^^^^^^^^^^^^^^^^^ The astropy modeling and fitting framework also allows for bounding the parameter values during the fitting process. However, not all astropy "Fitter" classes support parameter bounds. Please see `Fitting Model to Data `_ for more details. The model parameter bounds apply to all sources in the image, thus this mechanism cannot be used to bound the x and y positions of individual sources. However, the x and y positions can be bounded for individual sources during the fitting by using the ``xy_bounds`` keyword in `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry`. This keyword accepts a tuple of floats representing the maximum distance in pixels that a fitted source can be from its initial (x, y) position. For example, you may want to constrain the flux of a source to be between certain values or ensure that it is a non-negative value. This can be done by setting the ``bounds`` attribute on the input PSF model parameters. Here we constrain the flux to be greater than or equal to 0:: >>> psf_model3 = CircularGaussianPRF(flux=1, fwhm=2.7) >>> psf_model3.flux.bounds = (0, None) >>> psf_model3.bounds # doctest: +FLOAT_CMP {'flux': (0.0, None), 'x_0': (None, None), 'y_0': (None, None), 'fwhm': (0.0, None)} The model parameter ``bounds`` can also be set using the ``min`` and/or ``max`` attributes. Here we set the minimum flux to be 0:: >>> psf_model3.flux.min = 0 >>> psf_model3.bounds # doctest: +FLOAT_CMP {'flux': (0.0, None), 'x_0': (None, None), 'y_0': (None, None), 'fwhm': (0.0, None)} For this example, let's constrain the flux value to be between between 400 and 600:: >>> psf_model3 = CircularGaussianPRF(flux=1, fwhm=2.7) >>> psf_model3.flux.bounds = (400, 600) >>> psf_model3.bounds # doctest: +FLOAT_CMP {'flux': (400.0, 600.0), 'x_0': (None, None), 'y_0': (None, None), 'fwhm': (0.0, None)} Source Grouping ^^^^^^^^^^^^^^^ Source grouping is an optional feature. To turn it on, create a `~photutils.psf.SourceGrouper` instance and input it via the ``grouper`` keyword. Here we'll group stars 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 stars 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 star groups. Simultaneously fitting very large star groups is computationally expensive and error-prone. Internally, source grouping requires the creation of a compound Astropy model. Due to the way compound Astropy models are currently constructed, large groups also require excessively large amounts of memory; this will hopefully be fixed in a future Astropy version. A warning will be raised if the number of sources in a group exceeds 25. Local Background Subtraction ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ To subtract a local background from each source, define a `~photutils.background.LocalBackground` instance and input it via the ``localbkg_estimator`` keyword. Here we'll use an annulus with an inner and outer radius of 5 and 10 pixels, respectively, with the `~photutils.background.MMMBackground` statistic (with its default sigma clipping):: >>> from photutils.background import LocalBackground, MMMBackground >>> bkgstat = MMMBackground() >>> localbkg_estimator = LocalBackground(5, 10, bkgstat) >>> finder = DAOStarFinder(10.0, 2.0) >>> psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, ... grouper=grouper, aperture_radius=4, ... localbkg_estimator=localbkg_estimator) >>> phot = psfphot(data, error=error) The local background values are output in the table:: >>> phot['local_bkg'].info.format = '.4f' # optional format >>> print(phot[('id', 'local_bkg')]) # doctest: +FLOAT_CMP id local_bkg --- --------- 1 -0.0839 2 0.1784 3 0.2593 4 -0.0574 5 0.2492 6 -0.0818 7 -0.1130 8 -0.2166 9 0.0102 10 0.3926 The local background values can also be input directly using the ``init_params`` keyword. Iterative PSF Photometry ^^^^^^^^^^^^^^^^^^^^^^^^ Now let's use the `~photutils.psf.IterativePSFPhotometry` class to iteratively fit the stars in the image. This class is useful for crowded fields where faint stars are very close to bright stars. The faint stars may not be detected until after the bright stars are subtracted. For this simple example, let's input a table of three stars for the first fit iteration. Subsequent iterations will use the ``finder`` to find additional stars:: >>> from photutils.background import LocalBackground, MMMBackground >>> from photutils.psf import IterativePSFPhotometry >>> fit_shape = (5, 5) >>> finder = DAOStarFinder(10.0, 2.0) >>> bkgstat = MMMBackground() >>> localbkg_estimator = LocalBackground(5, 10, bkgstat) >>> init_params = QTable() >>> init_params['x'] = [54, 29, 80] >>> init_params['y'] = [8, 26, 29] >>> psfphot2 = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, ... localbkg_estimator=localbkg_estimator, ... aperture_radius=4) >>> phot = psfphot2(data, error=error, init_params=init_params) The table output from `~photutils.psf.IterativePSFPhotometry` contains a column called ``iter_detected`` that returns the fit iteration in which the source was detected:: >>> phot['x_fit'].info.format = '.4f' # optional format >>> phot['y_fit'].info.format = '.4f' >>> phot['flux_fit'].info.format = '.4f' >>> print(phot[('id', 'iter_detected', 'x_fit', 'y_fit', 'flux_fit')]) # doctest: +FLOAT_CMP id iter_detected x_fit y_fit flux_fit --- ------------- ------- ------- -------- 1 1 54.5665 7.7641 514.2650 2 1 29.0883 25.6092 534.0850 3 1 79.6273 28.7480 613.0496 4 2 63.2340 48.6415 564.1528 5 2 88.8856 54.1203 615.4907 6 2 79.8765 61.1359 649.9589 7 2 90.9631 72.0880 603.7433 8 2 7.8203 78.5821 641.8223 9 2 5.5350 89.8870 539.5237 10 2 71.8485 90.5830 687.4573 Estimating the FWHM of sources ------------------------------ The `photutils.psf` package also provides a convenience function called `~photutils.psf.fit_fwhm` to estimate the full width at half maximum (FWHM) of one or more sources in an image. This function fits the source(s) with a circular 2D Gaussian PRF model (`~photutils.psf.CircularGaussianPRF`) using the `~photutils.psf.PSFPhotometry` class. If your sources are not circular or non-Gaussian, you can fit your sources using the `~photutils.psf.PSFPhotometry` class using a different PSF model. For example, let's estimate the FWHM of the sources in our example image defined above:: >>> from photutils.psf import fit_fwhm >>> finder = DAOStarFinder(6.0, 2.0) >>> finder_tbl = finder(data) >>> xypos = list(zip(finder_tbl['xcentroid'], finder_tbl['ycentroid'])) >>> fwhm = fit_fwhm(data, xypos=xypos, error=error, fit_shape=(5, 5), fwhm=2) >>> fwhm # doctest: +FLOAT_CMP array([2.69735154, 2.70371211, 2.68917219, 2.69310558, 2.68931721, 2.69804194, 2.69651045, 2.70423936, 2.71458867, 2.70285813]) Convenience Gaussian Fitting Function ------------------------------------- The `photutils.psf` package also provides a convenience function called :func:`~photutils.psf.fit_2dgaussian` for fitting one or more sources with a 2D Gaussian PRF model (`~photutils.psf.CircularGaussianPRF`) using the `~photutils.psf.PSFPhotometry` class. See the function documentation for more details and examples. API Reference ------------- :doc:`../reference/psf_api` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/user_guide/psf_matching.rst0000644000175100001660000002220114755160622021435 0ustar00runnerdocker.. _psf_matching: PSF Matching (`photutils.psf.matching`) ======================================= Introduction ------------ This subpackage contains tools to generate kernels for matching point spread functions (PSFs). Matching PSFs ------------- Photutils provides a function called :func:`~photutils.psf.matching.create_matching_kernel` that generates a matching kernel between two PSFs using the ratio of Fourier transforms (see e.g., `Gordon et al. 2008`_; `Aniano et al. 2011`_). For this first simple example, let's assume our source and target PSFs are noiseless 2D Gaussians. The "high-resolution" PSF will be a Gaussian with :math:`\sigma=3`. The "low-resolution" PSF will be a Gaussian with :math:`\sigma=5`:: >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> y, x = np.mgrid[0:51, 0:51] >>> gm1 = Gaussian2D(100, 25, 25, 3, 3) >>> gm2 = Gaussian2D(100, 25, 25, 5, 5) >>> g1 = gm1(x, y) >>> g2 = gm2(x, y) >>> g1 /= g1.sum() >>> g2 /= g2.sum() For these 2D Gaussians, the matching kernel should be a 2D Gaussian with :math:`\sigma=4` (``sqrt(5**2 - 3**2)``). Let's create the matching kernel using a Fourier ratio method. Note that the input source and target PSFs must have the same shape and pixel scale:: >>> from photutils.psf.matching import create_matching_kernel >>> kernel = create_matching_kernel(g1, g2) Let's plot the result: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.psf.matching import create_matching_kernel y, x = np.mgrid[0:51, 0:51] gm1 = Gaussian2D(100, 25, 25, 3, 3) gm2 = Gaussian2D(100, 25, 25, 5, 5) g1 = gm1(x, y) g2 = gm2(x, y) g1 /= g1.sum() g2 /= g2.sum() kernel = create_matching_kernel(g1, g2) plt.imshow(kernel, cmap='Greys_r', origin='lower') plt.colorbar() We quickly observe that the result is not as expected. This is because of high-frequency noise in the Fourier transforms (even though these are noiseless PSFs, there is floating-point noise in the ratios). Using the Fourier ratio method, one must filter the high-frequency noise from the Fourier ratios. This is performed by inputting a `window function `_, which may be a function or a callable object. In general, the user will need to exercise some care when defining a window function. For more information, please see `Aniano et al. 2011`_. Photutils provides the following window classes: * `~photutils.psf.matching.HanningWindow` * `~photutils.psf.matching.TukeyWindow` * `~photutils.psf.matching.CosineBellWindow` * `~photutils.psf.matching.SplitCosineBellWindow` * `~photutils.psf.matching.TopHatWindow` Here are plots of 1D cuts across the center of the 2D window functions: .. plot:: import matplotlib.pyplot as plt from photutils.psf.matching import (CosineBellWindow, HanningWindow, SplitCosineBellWindow, TopHatWindow, TukeyWindow) w1 = HanningWindow() w2 = TukeyWindow(alpha=0.5) w3 = CosineBellWindow(alpha=0.5) w4 = SplitCosineBellWindow(alpha=0.4, beta=0.3) w5 = TopHatWindow(beta=0.4) shape = (101, 101) y0 = (shape[0] - 1) // 2 plt.figure() plt.subplots_adjust(left=0.1, bottom=0.1, right=0.95, top=0.93, wspace=0.5, hspace=0.5) plt.subplot(2, 3, 1) plt.plot(w1(shape)[y0, :]) plt.title('Hanning') plt.xlabel('x') plt.ylim((0, 1.1)) plt.subplot(2, 3, 2) plt.plot(w2(shape)[y0, :]) plt.title('Tukey') plt.xlabel('x') plt.ylim((0, 1.1)) plt.subplot(2, 3, 3) plt.plot(w3(shape)[y0, :]) plt.title('Cosine Bell') plt.xlabel('x') plt.ylim((0, 1.1)) plt.subplot(2, 3, 4) plt.plot(w4(shape)[y0, :]) plt.title('Split Cosine Bell') plt.xlabel('x') plt.ylim((0, 1.1)) plt.subplot(2, 3, 5) plt.plot(w5(shape)[y0, :], label='Top Hat') plt.title('Top Hat') plt.xlabel('x') plt.ylim((0, 1.1)) However, the user may input any function or callable object to generate a custom window function. In this example, because these are noiseless PSFs, we will use a `~photutils.psf.matching.TopHatWindow` object as the low-pass filter:: >>> from photutils.psf.matching import TopHatWindow >>> window = TopHatWindow(0.35) >>> kernel = create_matching_kernel(g1, g2, window=window) Note that the output matching kernel from :func:`~photutils.psf.matching.create_matching_kernel` is always normalized such that the kernel array sums to 1:: >>> print(kernel.sum()) # doctest: +FLOAT_CMP 1.0 Let's display the new matching kernel: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.psf.matching import TopHatWindow, create_matching_kernel y, x = np.mgrid[0:51, 0:51] gm1 = Gaussian2D(100, 25, 25, 3, 3) gm2 = Gaussian2D(100, 25, 25, 5, 5) g1 = gm1(x, y) g2 = gm2(x, y) g1 /= g1.sum() g2 /= g2.sum() window = TopHatWindow(0.35) kernel = create_matching_kernel(g1, g2, window=window) plt.imshow(kernel, cmap='Greys_r', origin='lower') plt.colorbar() As desired, the result is indeed a 2D Gaussian with a :math:`\sigma=4`. Here we will show 1D cuts across the center of the kernel images: .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.psf.matching import TopHatWindow, create_matching_kernel y, x = np.mgrid[0:51, 0:51] gm1 = Gaussian2D(100, 25, 25, 3, 3) gm2 = Gaussian2D(100, 25, 25, 5, 5) gm3 = Gaussian2D(100, 25, 25, 4, 4) g1 = gm1(x, y) g2 = gm2(x, y) g3 = gm3(x, y) g1 /= g1.sum() g2 /= g2.sum() g3 /= g3.sum() window = TopHatWindow(0.35) kernel = create_matching_kernel(g1, g2, window=window) kernel /= kernel.sum() plt.plot(kernel[25, :], label='Matching kernel') plt.plot(g3[25, :], label='$\\sigma=4$ Gaussian') plt.xlabel('x') plt.ylabel('Flux') plt.legend() plt.ylim((0.0, 0.011)) Matching IRAC PSFs ------------------ For this example, let's generate a matching kernel to go from the Spitzer/IRAC channel 1 (3.6 microns) PSF to the channel 4 (8.0 microns) PSF. We load the PSFs using the :func:`~photutils.datasets.load_irac_psf` convenience function:: >>> from photutils.datasets import load_irac_psf >>> ch1_hdu = load_irac_psf(channel=1) # doctest: +REMOTE_DATA >>> ch4_hdu = load_irac_psf(channel=4) # doctest: +REMOTE_DATA >>> ch1 = ch1_hdu.data # doctest: +REMOTE_DATA >>> ch4 = ch4_hdu.data # doctest: +REMOTE_DATA Let's display the images: .. plot:: import matplotlib.pyplot as plt from astropy.visualization import LogStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.datasets import load_irac_psf ch1_hdu = load_irac_psf(channel=1) ch4_hdu = load_irac_psf(channel=4) ch1 = ch1_hdu.data ch4 = ch4_hdu.data norm = ImageNormalize(stretch=LogStretch()) plt.figure(figsize=(9, 4)) plt.subplot(1, 2, 1) plt.imshow(ch1, norm=norm, cmap='viridis', origin='lower') plt.title('IRAC channel 1 PSF') plt.subplot(1, 2, 2) plt.imshow(ch4, norm=norm, cmap='viridis', origin='lower') plt.title('IRAC channel 4 PSF') For this example, we will use the :class:`~photutils.psf.matching.CosineBellWindow` for the low-pass window. Note that these Spitzer/IRAC channel 1 and 4 PSFs have the same shape and pixel scale. If that is not the case, one can use the :func:`~photutils.psf.matching.resize_psf` convenience function to resize a PSF image. Typically, one would interpolate the lower-resolution PSF to the same size as the higher-resolution PSF. .. doctest-skip:: >>> from photutils.psf.matching import (CosineBellWindow, ... create_matching_kernel) >>> window = CosineBellWindow(alpha=0.35) >>> kernel = create_matching_kernel(ch1, ch4, window=window) Let's display the matching kernel result: .. plot:: import matplotlib.pyplot as plt from astropy.visualization import LogStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.datasets import load_irac_psf from photutils.psf.matching import CosineBellWindow, create_matching_kernel ch1_hdu = load_irac_psf(channel=1) ch4_hdu = load_irac_psf(channel=4) ch1 = ch1_hdu.data ch4 = ch4_hdu.data norm = ImageNormalize(stretch=LogStretch()) window = CosineBellWindow(alpha=0.35) kernel = create_matching_kernel(ch1, ch4, window=window) plt.imshow(kernel, norm=norm, cmap='viridis', origin='lower') plt.colorbar() plt.title('Matching kernel') The Spitzer/IRAC channel 1 image could then be convolved with this matching kernel to produce an image with the same resolution as the channel-4 image. 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/user_guide/segmentation.rst0000644000175100001660000006227514755160622021507 0ustar00runnerdocker.. _image_segmentation: Image Segmentation (`photutils.segmentation`) ============================================= Introduction ------------ Photutils includes general-use functions to detect sources (both point-like and extended) in an image using a process called `image segmentation `_. After detecting sources using image segmentation, we can then measure their photometry, centroids, and shape properties. Source Extraction Using Image Segmentation ------------------------------------------ Image segmentation is a process of assigning a label to every pixel in an image such that pixels with the same label are part of the same source. Detected sources must have a minimum number of connected pixels that are each greater than a specified threshold value in an image. The threshold level is usually defined as some multiple of the background noise (sigma level) above the background. The image is usually filtered before thresholding to smooth the noise and maximize the detectability of objects with a shape similar to the filter kernel. Let's start by making a synthetic image provided by the :ref:`photutils.datasets ` module:: >>> from photutils.datasets import make_100gaussians_image >>> data = make_100gaussians_image() Next, we need to subtract the background from the image. In this example, we'll use the :class:`~photutils.background.Background2D` class to produce a background and background noise image:: >>> from photutils.background import Background2D, MedianBackground >>> bkg_estimator = MedianBackground() >>> bkg = Background2D(data, (50, 50), filter_size=(3, 3), ... bkg_estimator=bkg_estimator) >>> data -= bkg.background # subtract the background After subtracting the background, we need to define the detection threshold. In this example, we'll define a 2D detection threshold image using the background RMS image. We set the threshold at the 1.5-sigma (per pixel) noise level:: >>> threshold = 1.5 * bkg.background_rms Next, let's convolve the data with a 2D Gaussian kernel with a FWHM of 3 pixels:: >>> from astropy.convolution import convolve >>> from photutils.segmentation import make_2dgaussian_kernel >>> kernel = make_2dgaussian_kernel(3.0, size=5) # FWHM = 3.0 >>> convolved_data = convolve(data, kernel) Now we are ready to detect the sources in the background-subtracted convolved image. Let's find sources that have 10 connected pixels that are each greater than the corresponding pixel-wise ``threshold`` level defined above (i.e., 1.5 sigma per pixel above the background noise). Note that by default "connected pixels" means "8-connected" pixels, where pixels touch along their edges or corners. One can also use "4-connected" pixels that touch only along their edges by setting ``connectivity=4``:: >>> from photutils.segmentation import detect_sources >>> segment_map = detect_sources(convolved_data, threshold, npixels=10) >>> print(segment_map) shape: (300, 500) nlabels: 86 labels: [ 1 2 3 4 5 ... 82 83 84 85 86] The result is a :class:`~photutils.segmentation.SegmentationImage` object with the same shape as the data, where detected sources are labeled by different positive integer values. Background pixels (non-sources) always have a value of zero. Because the segmentation image is generated using image thresholding, the source segments represent the isophotal footprints of each source. Let's plot both the background-subtracted image and the segmentation image showing the detected sources: .. doctest-skip:: >>> import numpy as np >>> import matplotlib.pyplot as plt >>> from astropy.visualization import SqrtStretch >>> from astropy.visualization.mpl_normalize import ImageNormalize >>> norm = ImageNormalize(stretch=SqrtStretch()) >>> fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 12.5)) >>> ax1.imshow(data, origin='lower', cmap='Greys_r', norm=norm) >>> ax1.set_title('Background-subtracted Data') >>> ax2.imshow(segment_map, origin='lower', cmap=segment_map.cmap, ... interpolation='nearest') >>> ax2.set_title('Segmentation Image') .. plot:: import matplotlib.pyplot as plt from astropy.convolution import convolve from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.background import Background2D, MedianBackground from photutils.datasets import make_100gaussians_image from photutils.segmentation import detect_sources, make_2dgaussian_kernel data = make_100gaussians_image() bkg_estimator = MedianBackground() bkg = Background2D(data, (50, 50), filter_size=(3, 3), bkg_estimator=bkg_estimator) data -= bkg.background # subtract the background threshold = 1.5 * bkg.background_rms kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) segment_map = detect_sources(convolved_data, threshold, npixels=10) norm = ImageNormalize(stretch=SqrtStretch()) fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 12.5)) ax1.imshow(data, origin='lower', cmap='Greys_r', norm=norm) ax1.set_title('Background-subtracted Data') ax2.imshow(segment_map, origin='lower', cmap=segment_map.cmap, interpolation='nearest') ax2.set_title('Segmentation Image') plt.tight_layout() Source Deblending ----------------- In the example above, overlapping sources are detected as single sources. Separating those sources requires a deblending procedure, such as a multi-thresholding technique used by `SourceExtractor`_. Photutils provides a :func:`~photutils.segmentation.deblend_sources` function that deblends sources uses a combination of multi-thresholding and `watershed segmentation `_. Note that in order to deblend sources, they must be separated enough such that there is a saddle point between them. The amount of deblending can be controlled with the two :func:`~photutils.segmentation.deblend_sources` keywords ``nlevels`` and ``contrast``. ``nlevels`` is the number of multi-thresholding levels to use. ``contrast`` is the fraction of the total source flux that a local peak must have to be considered as a separate object. Here's a simple example of source deblending: .. doctest-requires:: skimage<=0.24 >>> from photutils.segmentation import deblend_sources >>> segm_deblend = deblend_sources(convolved_data, segment_map, ... npixels=10, nlevels=32, contrast=0.001, ... progress_bar=False) where ``segment_map`` is the :class:`~photutils.segmentation.SegmentationImage` that was generated by :func:`~photutils.segmentation.detect_sources`. Note that the ``convolved_data`` and ``npixels`` input values should match those used in :func:`~photutils.segmentation.detect_sources` to generate ``segment_map``. The result is a new :class:`~photutils.segmentation.SegmentationImage` object containing the deblended segmentation image: .. plot:: import matplotlib.pyplot as plt from astropy.convolution import convolve from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.background import Background2D, MedianBackground from photutils.datasets import make_100gaussians_image from photutils.segmentation import (deblend_sources, detect_sources, make_2dgaussian_kernel) data = make_100gaussians_image() bkg_estimator = MedianBackground() bkg = Background2D(data, (50, 50), filter_size=(3, 3), bkg_estimator=bkg_estimator) data -= bkg.background # subtract the background threshold = 1.5 * bkg.background_rms kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) npixels = 10 segment_map = detect_sources(convolved_data, threshold, npixels=npixels) segm_deblend = deblend_sources(convolved_data, segment_map, npixels=npixels, progress_bar=False) norm = ImageNormalize(stretch=SqrtStretch()) fig, ax = plt.subplots(1, 1, figsize=(10, 6.5)) ax.imshow(segm_deblend, origin='lower', cmap=segm_deblend.cmap, interpolation='nearest') ax.set_title('Deblended Segmentation Image') plt.tight_layout() Let's plot one of the deblended sources: .. plot:: import matplotlib.pyplot as plt from astropy.convolution import convolve from astropy.visualization import SqrtStretch from astropy.visualization.mpl_normalize import ImageNormalize from photutils.background import Background2D, MedianBackground from photutils.datasets import make_100gaussians_image from photutils.segmentation import (deblend_sources, detect_sources, make_2dgaussian_kernel) data = make_100gaussians_image() bkg_estimator = MedianBackground() bkg = Background2D(data, (50, 50), filter_size=(3, 3), bkg_estimator=bkg_estimator) data -= bkg.background # subtract the background threshold = 1.5 * bkg.background_rms kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) npixels = 10 segment_map = detect_sources(convolved_data, threshold, npixels=npixels) segm_deblend = deblend_sources(convolved_data, segment_map, npixels=npixels, progress_bar=False) norm = ImageNormalize(stretch=SqrtStretch()) fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(10, 4)) slc = (slice(273, 297), slice(425, 444)) ax1.imshow(data[slc], origin='lower') ax1.set_title('Background-subtracted Data') cmap1 = segment_map.cmap ax2.imshow(segment_map.data[slc], origin='lower', cmap=cmap1, interpolation='nearest') ax2.set_title('Original Segment') cmap2 = segm_deblend.cmap ax3.imshow(segm_deblend.data[slc], origin='lower', cmap=cmap2, interpolation='nearest') ax3.set_title('Deblended Segments') plt.tight_layout() SourceFinder ------------ The :class:`~photutils.segmentation.SourceFinder` class is a convenience class that combines the functionality of `~photutils.segmentation.detect_sources` and `~photutils.segmentation.deblend_sources`. After defining the object with the desired detection and deblending parameters, you call it with the background-subtracted (convolved) image and threshold: .. doctest-requires:: skimage<=0.24 >>> from photutils.segmentation import SourceFinder >>> finder = SourceFinder(npixels=10, progress_bar=False) >>> segment_map = finder(convolved_data, threshold) >>> print(segment_map) shape: (300, 500) nlabels: 93 labels: [ 1 2 3 4 5 ... 89 90 91 92 93] Modifying a Segmentation Image ------------------------------ The :class:`~photutils.segmentation.SegmentationImage` object provides several methods that can be used to modify itself (e.g., combining labels, removing labels, removing border segments) prior to measuring source photometry and other source properties, including: * :meth:`~photutils.segmentation.SegmentationImage.reassign_label`: Reassign one or more label numbers. * :meth:`~photutils.segmentation.SegmentationImage.relabel_consecutive`: Reassign the label numbers consecutively, such that there are no missing label numbers. * :meth:`~photutils.segmentation.SegmentationImage.keep_labels`: Keep only the specified labels. * :meth:`~photutils.segmentation.SegmentationImage.remove_labels`: Remove one or more labels. * :meth:`~photutils.segmentation.SegmentationImage.remove_border_labels`: Remove labeled segments near the image border. * :meth:`~photutils.segmentation.SegmentationImage.remove_masked_labels`: Remove labeled segments located within a masked region. Photometry, Centroids, and Shape Properties ------------------------------------------- The :class:`~photutils.segmentation.SourceCatalog` class is the primary tool for measuring the photometry, centroids, and shape/morphological properties of sources defined in a segmentation image. In its most basic form, it takes as input the (background-subtracted) image and the segmentation image. Usually the convolved image is also input, from which the source centroids and shape/morphological properties are measured (if not input, the unconvolved image is used instead). Let's continue our example from above and measure the properties of the detected sources: .. doctest-requires:: skimage<=0.24 >>> from photutils.segmentation import SourceCatalog >>> cat = SourceCatalog(data, segm_deblend, convolved_data=convolved_data) >>> print(cat) Length: 93 labels: [ 1 2 3 4 5 ... 89 90 91 92 93] The source properties can be accessed using `~photutils.segmentation.SourceCatalog` attributes or output to an Astropy `~astropy.table.QTable` using the :meth:`~photutils.segmentation.SourceCatalog.to_table` method. Please see :class:`~photutils.segmentation.SourceCatalog` for the many properties that can be calculated for each source. More properties are likely to be added in the future. Here we'll use the :meth:`~photutils.segmentation.SourceCatalog.to_table` method to generate a `~astropy.table.QTable` of source properties. Each row in the table represents a source. The columns represent the calculated source properties. The ``label`` column corresponds to the label value in the input segmentation image. Note that only a small subset of the source properties are shown below: .. doctest-requires:: skimage<=0.24 >>> tbl = cat.to_table() >>> tbl['xcentroid'].info.format = '.2f' # optional format >>> tbl['ycentroid'].info.format = '.2f' >>> tbl['kron_flux'].info.format = '.2f' >>> print(tbl) label xcentroid ycentroid ... segment_fluxerr kron_flux kron_fluxerr ... ----- --------- --------- ... --------------- --------- ------------ 1 235.38 1.44 ... nan 490.35 nan 2 493.78 5.84 ... nan 489.37 nan 3 207.29 10.26 ... nan 694.24 nan 4 364.87 11.13 ... nan 681.20 nan 5 257.85 12.18 ... nan 748.18 nan ... ... ... ... ... ... ... 89 292.77 244.93 ... nan 792.63 nan 90 32.66 241.24 ... nan 930.77 nan 91 42.60 249.43 ... nan 580.54 nan 92 433.80 280.74 ... nan 663.44 nan 93 434.03 288.88 ... nan 879.64 nan Length = 93 rows The error columns are NaN because we did not input an error array (see the :ref:`photutils-segmentation_errors` section below). Let's plot the calculated elliptical Kron apertures (based on the shapes of each source) on the data: .. doctest-skip:: >>> import numpy as np >>> import matplotlib.pyplot as plt >>> from astropy.visualization import simple_norm >>> norm = simple_norm(data, 'sqrt') >>> fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 12.5)) >>> ax1.imshow(data, origin='lower', cmap='Greys_r', norm=norm) >>> ax1.set_title('Data') >>> ax2.imshow(segm_deblend, origin='lower', cmap=segm_deblend.cmap, ... interpolation='nearest') >>> ax2.set_title('Segmentation Image') >>> cat.plot_kron_apertures(ax=ax1, color='white', lw=1.5) >>> cat.plot_kron_apertures(ax=ax2, color='white', lw=1.5) .. plot:: import matplotlib.pyplot as plt from astropy.convolution import convolve from astropy.visualization import simple_norm from photutils.background import Background2D, MedianBackground from photutils.datasets import make_100gaussians_image from photutils.segmentation import (SourceCatalog, SourceFinder, make_2dgaussian_kernel) data = make_100gaussians_image() bkg_estimator = MedianBackground() bkg = Background2D(data, (50, 50), filter_size=(3, 3), bkg_estimator=bkg_estimator) data -= bkg.background # subtract the background threshold = 1.5 * bkg.background_rms kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) npixels = 10 finder = SourceFinder(npixels=npixels, progress_bar=False) segment_map = finder(convolved_data, threshold) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data) fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 12.5)) norm = simple_norm(data, 'sqrt') ax1.imshow(data, origin='lower', cmap='Greys_r', norm=norm) ax1.set_title('Data with Kron apertures') ax2.imshow(segment_map, origin='lower', cmap=segment_map.cmap, interpolation='nearest') ax2.set_title('Segmentation Image with Kron apertures') cat.plot_kron_apertures(ax=ax1, color='white', lw=1.5) cat.plot_kron_apertures(ax=ax2, color='white', lw=1.5) plt.tight_layout() We can also create a `~photutils.segmentation.SourceCatalog` object containing only a specific subset of sources, defined by their label numbers in the segmentation image: .. doctest-requires:: skimage<=0.24 >>> cat = SourceCatalog(data, segm_deblend, convolved_data=convolved_data) >>> labels = [1, 5, 20, 50, 75, 80] >>> cat_subset = cat.get_labels(labels) >>> tbl2 = cat_subset.to_table() >>> tbl2['xcentroid'].info.format = '.2f' # optional format >>> tbl2['ycentroid'].info.format = '.2f' >>> tbl2['kron_flux'].info.format = '.2f' >>> print(tbl2) label xcentroid ycentroid ... segment_fluxerr kron_flux kron_fluxerr ... ----- --------- --------- ... --------------- --------- ------------ 1 235.38 1.44 ... nan 490.35 nan 5 257.85 12.18 ... nan 748.18 nan 20 347.17 66.45 ... nan 855.34 nan 50 381.02 174.67 ... nan 438.55 nan 75 74.44 259.78 ... nan 876.02 nan 80 14.93 60.06 ... nan 878.52 nan By default, the :meth:`~photutils.segmentation.SourceCatalog.to_table` includes only a small subset of source properties. The output table properties can be customized in the `~astropy.table.QTable` using the ``columns`` keyword: .. doctest-requires:: skimage<=0.24 >>> cat = SourceCatalog(data, segm_deblend, convolved_data=convolved_data) >>> labels = [1, 5, 20, 50, 75, 80] >>> cat_subset = cat.get_labels(labels) >>> columns = ['label', 'xcentroid', 'ycentroid', 'area', 'segment_flux'] >>> tbl3 = cat_subset.to_table(columns=columns) >>> tbl3['xcentroid'].info.format = '.4f' # optional format >>> tbl3['ycentroid'].info.format = '.4f' >>> tbl3['segment_flux'].info.format = '.4f' >>> print(tbl3) label xcentroid ycentroid area segment_flux pix2 ----- --------- --------- ----- ------------ 1 235.3827 1.4439 47.0 433.3546 5 257.8501 12.1764 84.0 489.9653 20 347.1743 66.4462 103.0 625.9668 50 381.0186 174.6745 50.0 249.0170 75 74.4448 259.7843 66.0 836.4803 80 14.9296 60.0641 87.0 666.6014 A `~astropy.wcs.WCS` transformation can also be input to :class:`~photutils.segmentation.SourceCatalog` via the ``wcs`` keyword, in which case the sky coordinates of the source centroids can be calculated. Background Properties ^^^^^^^^^^^^^^^^^^^^^ Like with :func:`~photutils.aperture.aperture_photometry`, the ``data`` array that is input to :class:`~photutils.segmentation.SourceCatalog` should be background subtracted. If you input the background image that was subtracted from the data into the ``background`` keyword of :class:`~photutils.segmentation.SourceCatalog`, the background properties for each source will also be calculated: .. doctest-requires:: skimage<=0.24 >>> cat = SourceCatalog(data, segm_deblend, background=bkg.background) >>> labels = [1, 5, 20, 50, 75, 80] >>> cat_subset = cat.get_labels(labels) >>> columns = ['label', 'background_centroid', 'background_mean', ... 'background_sum'] >>> tbl4 = cat_subset.to_table(columns=columns) >>> tbl4['background_centroid'].info.format = '{:.10f}' # optional format >>> tbl4['background_mean'].info.format = '{:.10f}' >>> tbl4['background_sum'].info.format = '{:.10f}' >>> print(tbl4) label background_centroid background_mean background_sum ----- ------------------- --------------- -------------- 1 5.2383296240 5.1952756242 244.1779543392 5 5.2926300845 5.2065435089 437.3496547461 20 5.2901502015 5.2182858995 537.4834476464 50 5.0822645472 5.2277566101 261.3878305070 75 5.1889235577 5.2203644547 344.5440540106 80 5.2014082564 5.2174773439 453.9205289152 .. _photutils-segmentation_errors: Photometric Errors ^^^^^^^^^^^^^^^^^^ :class:`~photutils.segmentation.SourceCatalog` requires inputting a *total* error array, i.e., the background-only error plus Poisson noise due to individual sources. The :func:`~photutils.utils.calc_total_error` function can be used to calculate the total error array from a background-only error array and an effective gain. The ``effective_gain``, which is the ratio of counts (electrons or photons) to the units of the data, is used to include the Poisson noise from the sources. ``effective_gain`` can either be a scalar value or a 2D image with the same shape as the ``data``. A 2D effective gain image is useful for mosaic images that have variable depths (i.e., exposure times) across the field. For example, one should use an exposure-time map as the ``effective_gain`` for a variable depth mosaic image in count-rate units. Let's assume our synthetic data is in units of electrons per second. In that case, the ``effective_gain`` should be the exposure time (here we set it to 500 seconds). Here we use :func:`~photutils.utils.calc_total_error` to calculate the total error and input it into the :class:`~photutils.segmentation.SourceCatalog` class. When a total ``error`` is input, the `~photutils.segmentation.SourceCatalog.segment_fluxerr` and `~photutils.segmentation.SourceCatalog.kron_fluxerr` properties are calculated. `~photutils.segmentation.SourceCatalog.segment_flux` and `~photutils.segmentation.SourceCatalog.segment_fluxerr` are the instrumental flux and propagated flux error within the source segments: .. doctest-requires:: skimage<=0.24 >>> from photutils.utils import calc_total_error >>> effective_gain = 500.0 >>> error = calc_total_error(data, bkg.background_rms, effective_gain) >>> cat = SourceCatalog(data, segm_deblend, error=error) >>> labels = [1, 5, 20, 50, 75, 80] >>> cat_subset = cat.get_labels(labels) # select a subset of objects >>> columns = ['label', 'xcentroid', 'ycentroid', 'segment_flux', ... 'segment_fluxerr'] >>> tbl5 = cat_subset.to_table(columns=columns) >>> tbl5['xcentroid'].info.format = '{:.4f}' # optional format >>> tbl5['ycentroid'].info.format = '{:.4f}' >>> tbl5['segment_flux'].info.format = '{:.4f}' >>> tbl5['segment_fluxerr'].info.format = '{:.4f}' >>> for col in tbl5.colnames: ... tbl5[col].info.format = '%.8g' # for consistent table output >>> print(tbl5) label xcentroid ycentroid segment_flux segment_fluxerr ----- --------- --------- ------------ --------------- 1 235.24302 1.1928271 433.35463 14.167067 5 257.82267 12.228232 489.96534 18.998371 20 347.15384 66.417567 625.96683 22.475065 50 380.94448 174.57181 249.01701 15.261334 75 74.413068 259.76066 836.4803 17.193721 80 14.920217 60.024006 666.6014 19.605394 Pixel Masking ^^^^^^^^^^^^^ Pixels can be completely ignored/excluded (e.g., bad pixels) when measuring the source properties by providing a boolean mask image via the ``mask`` keyword (`True` pixel values are masked) to the :class:`~photutils.segmentation.SourceCatalog` class. Note that non-finite ``data`` values (NaN and inf) are automatically masked. Filtering ^^^^^^^^^ `SourceExtractor`_'s centroid and morphological parameters are always calculated from a convolved, or filtered, "detection" image (``convolved_data``), i.e., the image used to define the segmentation image. The usual downside of the filtering is the sources will be made more circular than they actually are. If you wish to reproduce `SourceExtractor`_ centroid and morphology results, then input the ``convolved_data`` (or ``kernel``, but not both). If ``convolved_data`` and ``kernel`` are both `None`, then the unfiltered ``data`` will be used for the source centroid and morphological parameters. Note that photometry is *always* performed on the unfiltered ``data``. API Reference ------------- :doc:`../reference/segmentation_api` .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/user_guide/utils.rst0000644000175100001660000000171314755160622020140 0ustar00runnerdockerUtility 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.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.make_random_cmap`: Function to create a colormap consisting of random muted colors. This type of colormap is useful for plotting segmentation images. API Reference ------------- :doc:`../reference/utils_api` ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6869266 photutils-2.2.0/docs/whats_new/0000755000175100001660000000000014755160634016113 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/whats_new/1.1.rst0000644000175100001660000000523714755160622017150 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.1? **************************** Overview ======== Photutils 1.1 is a major release that adds new functionality since the 1.0 release. Here we highlight some of the major changes. Please see the :ref:`changelog` for the complete list of changes. New SourceCatalog class ======================= A new, significantly faster, `~photutils.segmentation.SourceCatalog` class was implemented. This new class simplifies the API and takes the place of the ``source_properties`` function and the ``SourceProperties`` ``LegacySourceCatalog`` classes. The ``source_properties`` function and ``SourceProperties`` class are now deprecated and will eventually be removed. The ``source_properties`` function now returns ``LegacySourceCatalog`` class (deprecated) to distinguish it from the new `~photutils.segmentation.SourceCatalog`. Optional keyword arguments in `~photutils.segmentation.SourceCatalog` can not be input as positional arguments. Please see the `~photutils.segmentation.SourceCatalog` documentation for the keywords inputs and their allowed values. Renamed properties ------------------ Note that many of the source properties have been slightly renamed in the new `~photutils.segmentation.SourceCatalog` class, e.g., * 'id' -> 'label' * 'background_at_centroid' -> 'background_centroid' * 'background_cutout' -> 'background' * 'background_cutout_ma' -> 'background_ma' * 'data_cutout' -> 'data' * 'data_cutout_ma' -> 'data_ma' * 'error_cutout' -> 'error' * 'error_cutout_ma' -> 'error_ma' * 'filtered_data_cutout_ma' -> 'convdata_ma' * 'minval_pos' -> 'minval_index' * 'minval_xpos' -> 'minval_xindex' * 'minval_ypos' -> 'minval_yindex' * 'maxval_pos' -> 'maxval_index' * 'maxval_xpos' -> 'maxval_xindex' * 'maxval_ypos' -> 'maxval_yindex' * 'semimajor_axis_sigma' -> 'semimajor_sigma' * 'semiminor_axis_sigma' -> 'semiminor_sigma' * 'source_sum' -> 'segment_flux' * 'source_sum_err' -> 'segment_fluxerr' Also, the 'centroid' and 'cutout_centroid' properties now return centroids in (x, y) order to be consistent with the tools in ``photutils.centroid``. New methods and attributes -------------------------- The new `~photutils.segmentation.SourceCatalog` class has the following new methods: * :meth:`~photutils.segmentation.SourceCatalog.circular_photometry` * :meth:`~photutils.segmentation.SourceCatalog.fluxfrac_radius` * :meth:`~photutils.segmentation.SourceCatalog.get_label` * :meth:`~photutils.segmentation.SourceCatalog.get_labels` and new attributes: * :attr:`~photutils.segmentation.SourceCatalog.fwhm` * :attr:`~photutils.segmentation.SourceCatalog.segment` * :attr:`~photutils.segmentation.SourceCatalog.segment_ma` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/whats_new/1.10.rst0000644000175100001660000000315514755160622017225 0ustar00runnerdocker.. doctest-skip-all ***************************** What's New in Photutils 1.10? ***************************** Here we highlight some of the new functionality of the 1.10 release. In addition to these changes, Photutils 1.10 includes a large number of smaller improvements and bug fixes, which are described in the full :ref:`changelog`. Reading WebbPSF Gridded PSF Models ---------------------------------- The `~photutils.psf.GriddedPSFModel` ``read`` method can now read FITS files containing ePSF grids that were generated by `WebbPSF `_. Minimum separation parameter for ``DAOStarFinder`` and ``IRAFStarFinder`` ------------------------------------------------------------------------- An optional ``min_separation`` keyword is now available in the `~photutils.detection.DAOStarFinder` and `~photutils.detection.IRAFStarFinder` classes. This parameter defines a minimum separation (in pixels) for detected objects. PSF photometry models --------------------- A `~photutils.psf.make_psf_model` function was added for making a PSF model from a 2D Astropy model. Compound models are also supported. The output PSF model can be used in the PSF photometry classes. This function replaces the deprecated ``~photutils.psf.prepare_psf_model`` function. ``GriddedPSFModel`` oversampling -------------------------------- The `~photutils.psf.GriddedPSFModel` oversampling can now be different in the x and y directions. The ``oversampling`` attribute is now stored as a 1D `numpy.ndarray` with two elements. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/whats_new/1.11.rst0000644000175100001660000000416314755160622017226 0ustar00runnerdocker.. doctest-skip-all ***************************** What's New in Photutils 1.11? ***************************** Here we highlight some of the new functionality of the 1.11 release. In addition to these changes, Photutils 1.11 includes a large number of smaller improvements and bug fixes, which are described in the full :ref:`changelog`. ``SourceFinder`` ``npixels`` tuple input ---------------------------------------- The `~photutils.segmentation.SourceFinder` ``npixels`` keyword can now be a tuple corresponding to the values used for the source finder and source deblender, respectively. ``GriddedPSFModel`` Memory Usage -------------------------------- The memory usage during PSF photometry when using a `~photutils.psf.GriddedPSFModel` PSF model has been significantly reduced. This is especially noticeable when fitting a large number of stars. New ``IterativePSFPhotometry`` ``mode`` keyword ----------------------------------------------- A ``mode`` keyword was added to `~photutils.psf.IterativePSFPhotometry` for controlling the fitting mode. The ``mode`` keyword can be set to 'new' or 'all'. For the 'new' mode (default), PSF photometry is run in each iteration only on the new sources detected in the residual image. The 'new' mode preserves the previous behavior of `~photutils.psf.IterativePSFPhotometry`. For the 'all' mode, PSF photometry is run in each iteration on all the detected sources (from all previous iterations) on the original, unsubtracted, data. For the 'all' mode, a source grouper must be input. Initial tests indicate that the 'all' mode may give better results than the older 'new' method. New ``include_localbkg`` keyword -------------------------------- The PSF photometry ``make_model_image`` and ``make_residual_image`` methods no longer include the local background by default, which causes issues if the ``psf_shape`` of sources overlap. This is a backwards-incompatible change. These methods now accept an ``include_localbkg`` keyword . If the previous behavior is desired, set ``include_localbkg=True``. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/whats_new/1.12.rst0000644000175100001660000000041314755160622017221 0ustar00runnerdocker.. doctest-skip-all ***************************** What's New in Photutils 1.12? ***************************** The Photutils 1.12 release was made to support NumPy 2.0. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/whats_new/1.13.rst0000644000175100001660000001244314755160622017230 0ustar00runnerdocker.. doctest-skip-all ***************************** What's New in Photutils 1.13? ***************************** Here we highlight some of the new functionality of the 1.13 release. In addition to these changes, Photutils 1.13 includes a large number of smaller improvements and bug fixes, which are described in the full :ref:`changelog`. Testing the installed version of Photutils ========================================== To test your installed version of Photutils, you can run the test suite using the `pytest `_ command. Running the test suite requires installing the `pytest-astropy `_ (0.11 or later) package. To run the test suite, use the following command:: pytest --pyargs photutils This method replaces the old method of running the test suite using the ``photutils.test()`` Python function, which has been removed. Datasets subpackage reoganization ================================= The ``photutils.datasets`` subpackage has been reorganized and the ``make`` module has been deprecated. Instead of importing functions from ``photutils.datasets.make``, import functions from ``photutils.datasets``. Changed noise pixel values in example datasets ============================================== The randomly-generated optional noise in the simulated example images returned by the ``make_4gaussians_image`` and ``make_100gaussians_image`` is now slightly different. The noise sigma is the same, but the pixel values differ. This is due to a change from the legacy NumPy random number generator to the redesigned and preferred random number generator introduced in NumPy 1.17. Making simulated images with model sources ========================================== The new :func:`~photutils.datasets.make_model_image` function creates a simulated image with model sources. This function is useful for testing source detection and photometry algorithms. This function has more options and is significantly faster than the now-deprecated ``mask_model_sources_image`` function. A new :func:`~photutils.datasets.make_model_params` function was also added to make a table of randomly generated model positions, fluxes, or other parameters for simulated sources. These two new functions along with the existing :func:`~photutils.datasets.make_random_models_table` function provide a complete set of tools for creating simulated images with model sources. Please see the examples in the documentation of these functions. The ``make_model_sources_image``, ``make_gaussian_sources_image``, ``make_gaussian_prf_sources_image``, ``make_test_psf_data``, and ``make_random_gaussians_table`` functions are now deprecated and will be removed in a future release. Making simulated images with a PSF model ======================================== A specialized function, :func:`~photutils.psf.make_psf_model_image` function was added to generate simulated images from a PSF model. This function returns both an image and a table of the model parameters. PSF photometry initial parameter guesses ======================================== The ``init_params`` table input when calling the `~photutils.psf.PSFPhotometry` or `~photutils.psf.IterativePSFPhotometry` class now allows the user to input columns for additional model parameters other than x, y, and flux if those parameters are free to vary in the fitting routine (i.e., not fixed parameters). The column names must match the parameter names in the PSF model. They can also be suffixed with either the "_init" or "_fit" suffix. Removed deprecated PSF photometry tools ======================================= The deprecated ``BasicPSFPhotometry``, ``IterativelySubtractedPSFPhotometry``, ``DAOPhotPSFPhotometry``, ``DAOGroup``, ``DBSCANGroup``, and ``GroupStarsBase``, and ``NonNormalizable`` classes and the ``prepare_psf_model``, ``get_grouped_psf_model``, and ``subtract_psf`` functions were removed. Updates to Star finders ======================= The `~photutils.detection.DAOStarFinder`, `~photutils.detection.IRAFStarFinder`, and `~photutils.detection.StarFinder` classes and the `~photutils.detection.find_peaks` functions now support input arrays with units. This requires inputing a ``threshold`` value that also has compatible units to the input data array. Sources that have non-finite properties (e.g., centroid, roundness, sharpness, etc.) are now automatically excluded from the output table in `~photutils.detection.DAOStarFinder`, `~photutils.detection.IRAFStarFinder`, and `~photutils.detection.StarFinder`. The ``sky`` keyword in `~photutils.detection.DAOStarFinder`, and `~photutils.detection.IRAFStarFinder` is now deprecated and will be removed in a future version. One should background subtract the image before calling the star finders. Improvements to Radial Profile tools ===================================== The `~photutils.profiles.CurveOfGrowth` class now has ``calc_ee_from_radius`` and ``calc_radius_from_ee`` methods to calculate the encircled energy (EE) at a given radius and vice versa using a cubic interpolator. The `~photutils.profiles.CurveOfGrowth` and `~photutils.profiles.RadialProfile` classes now have a ``unnormalize`` method to return the profile to the state before any ``normalize`` calls were run. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/whats_new/1.2.rst0000644000175100001660000000025714755160622017146 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.2? **************************** Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/whats_new/1.3.rst0000644000175100001660000000026014755160622017141 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.3? **************************** Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/whats_new/1.4.rst0000644000175100001660000000632014755160622017145 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.4? **************************** New ApertureStats class ======================= A new :class:`~photutils.aperture.ApertureStats` class was added. This class can be used to compute statistics of unmasked pixel within an aperture. It can be used to create a catalog of properties, including local-background subtracted aperture photometry with the "exact", "center", or "subpixel" method, for sources. The :class:`~photutils.aperture.ApertureStats` class can calculate many properties, including statistics like :attr:`~photutils.aperture.ApertureStats.min`, :attr:`~photutils.aperture.ApertureStats.max`, :attr:`~photutils.aperture.ApertureStats.mean`, :attr:`~photutils.aperture.ApertureStats.median`, :attr:`~photutils.aperture.ApertureStats.std`, :attr:`~photutils.aperture.ApertureStats.sum_aper_area`, and :attr:`~photutils.aperture.ApertureStats.sum`. It also can be used to calculate morphological properties like :attr:`~photutils.aperture.ApertureStats.centroid`, :attr:`~photutils.aperture.ApertureStats.fwhm`, :attr:`~photutils.aperture.ApertureStats.semimajor_sigma`, :attr:`~photutils.aperture.ApertureStats.semiminor_sigma`, :attr:`~photutils.aperture.ApertureStats.orientation`, and :attr:`~photutils.aperture.ApertureStats.eccentricity`. The properties can be accessed using `~photutils.aperture.ApertureStats` attributes or output to an Astropy `~astropy.table.QTable` using the :meth:`~photutils.aperture.ApertureStats.to_table` method. Please see :class:`~photutils.aperture.ApertureStats` for the the complete list of properties that can be calculated and the :ref:`photutils-aperture-stats` documentation for examples. New clip keyword in BkgZoomInterpolator ======================================= A ``clip`` keyword was added to the :class:`~photutils.background.BkgZoomInterpolator` class, which is used by :class:`~photutils.background.Background2D`. By default, :class:`~photutils.background.BkgZoomInterpolator` sets ``clip=True`` to prevent the interpolation from producing values outside the given input range. If backwards-compatiblity is needed with older Photutils versions, set ``clip=False``. Segmentation Performance Improvements ===================================== A ``convolved_data`` keyword was added to the :class:`~photutils.segmentation.SourceCatalog` class that allows the convolved image to be directly input instead of using the ``kernel`` keyword. Convolved data can also be directly input to the `~photutils.segmentation.detect_sources` and `~photutils.segmentation.deblend_sources` functions (using the ``data`` parameter) instead of using the ``kernel`` keyword. For performance, it is strongly recommended that the user first convolve their data, if desired, and then input the convolved data to each of these segmentation functions. Doing so improves the overall performance by omitting extra convolution steps within each function or class. Significant improvements were also made to the performance of the :class:`~photutils.segmentation.SegmentationImage` and `~photutils.segmentation.SourceCatalog` classes in the case of large data arrays. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/whats_new/1.5.rst0000644000175100001660000001053714755160622017153 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.5? **************************** Smoothing data prior to source detection and deblending ======================================================= The ``kernel`` keyword was deprecated from the `~photutils.segmentation.detect_sources` and `~photutils.segmentation.deblend_sources` functions. Instead the user should create a (background-subtracted) convolved image and input it directly into these functions. Doing so improves the overall performance by omitting extra convolution steps within each function or class. Both the (background subtracted) unconvolved and convolved images should be input into the `~photutils.segmentation.SourceCatalog` class. A `~photutils.segmentation.make_2dgaussian_kernel` convenience function was added for creating 2D Gaussian kernels. New SourceFinder class ====================== A new :class:`~photutils.segmentation.SourceFinder` convenience class was added, combining source detection and deblending. The `~photutils.segmentation.detect_sources` and `~photutils.segmentation.deblend_sources` functions also still remain available. The separate tools can be used, for example, to effienciently explore the various deblending parameters. Source Deblending Performance Improvements ========================================== The performance of the `~photutils.segmentation.deblend_sources` has been significantly improved. Also, `~photutils.segmentation.deblend_sources` and `~photutils.segmentation.SourceFinder` now have a ``nproc`` keyword to enable multiprocessing during source deblending. Please note that due to overheads, multiprocessing may be slower than serial processing. This is especially true if one only has a small number of sources to deblend. The benefits of multiprocessing require ~1000 or more sources to deblend, with larger gains as the number of sources increase. Also, a new ``sinh`` multi-thresholding mode was added to `~photutils.segmentation.deblend_sources` (also available in the new `~photutils.segmentation.SourceFinder`). New `~photutils.segmentation.SegmentationImage` methods ======================================================= `~photutils.segmentation.SegmentationImage` has a new `~photutils.segmentation.SegmentationImage.make_source_mask` method to create a source mask by dilating the segmentation image with a user-defined footprint. A new `~photutils.utils.circular_footprint` convenience function was added to create circular footprints. There is also a new `~photutils.segmentation.SegmentationImage.imshow` convenience method for plotting the segmentation image. `~photutils.segmentation.SourceCatalog` minimum Kron radius =========================================================== A minimum value for the unscaled Kron radius can now be specified as the second element of the ``kron_params`` keyword input to `~photutils.segmentation.SourceCatalog`. The ``kron_params`` keyword now has an optional third item representing the minimum circular radius. Custom cutouts from `~photutils.segmentation.SourceCatalog` =========================================================== The `~photutils.segmentation.SourceCatalog` has a new `~photutils.segmentation.SourceCatalog.make_cutouts` method for making custom-sized image cutouts for each labeled source centered at their centroid. The cutouts are instances of a new `~photutils.utils.CutoutImage` class. PSF-Fitting Masks ================= The ``~photutils.psf.BasicPSFPhotometry``, ``~photutils.psf.IterativelySubtractedPSFPhotometry`` and ``~photutils.psf.DAOPhotPSFPhotometry`` PSF-fitting instances now accept a ``mask`` keyword when called with the input data to mask bad pixels. Invalid data values (i.e., NaN or inf) are now automatically masked when performing PSF fitting. The Astropy/Scipy fitters do not actually perform a fit if such invalid values are in the data. Keyword-only arguments are now required for PSF tools ===================================================== Keyword arguments used in the PSF tools must now be explicitly input using the keyword name. Progress Bars ============= The `~photutils.segmentation.deblend_sources` function and the `~photutils.psf.EPSFBuilder` class now have options to use a progress bar using the new `tqdm `_ optional dependency. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/whats_new/1.6.rst0000644000175100001660000000641614755160622017155 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.6? **************************** Here we highlight some of the new functionality of the 1.6 release. In addition to these major changes, Photutils 1.6 includes a large number of smaller improvements and bug fixes, which are described in the full :ref:`changelog`. New centroids available in SourceCatalog ======================================== New centroids were added to the :class:`~photutils.segmentation.SourceCatalog` class, including iteratively-calculated "windowed" centroids and centroids calculated by fitting a 2D quadratic polynomial to the unmasked pixels in the source segment. The "windowed" centroids are equivalent the SourceExtractors ``XWIN_IMAGE`` and ``YWIN_IMAGE`` parameters. The new "windowed" centroid properties are: * :attr:`~photutils.segmentation.SourceCatalog.centroid_win` * :attr:`~photutils.segmentation.SourceCatalog.cutout_centroid_win` * :attr:`~photutils.segmentation.SourceCatalog.xcentroid_win` * :attr:`~photutils.segmentation.SourceCatalog.ycentroid_win` * :attr:`~photutils.segmentation.SourceCatalog.sky_centroid_win` The "quadratic" centroids are calculated using `~photutils.centroids.centroid_quadratic`. The new quadratic centroid properties are: * :attr:`~photutils.segmentation.SourceCatalog.centroid_quad` * :attr:`~photutils.segmentation.SourceCatalog.cutout_centroid_quad` * :attr:`~photutils.segmentation.SourceCatalog.xcentroid_quad` * :attr:`~photutils.segmentation.SourceCatalog.ycentroid_quad` * :attr:`~photutils.segmentation.SourceCatalog.sky_centroid_quad` Slicing a SegmentationImage =========================== :class:`~photutils.segmentation.SegmentationImage` objects can now be sliced in x and y, generating a new :class:`~photutils.segmentation.SegmentationImage` object. New ImageDepth class ==================== A new :class:`~photutils.utils.ImageDepth` class was added to compute the limiting fluxes and magnitudes of an image. ApertureStats ============= The :class:`~photutils.aperture.ApertureStats` class now accepts `~astropy.nddata.NDData` objects as input. Progress Bars in SourceCatalog and PSF fitting ============================================== An ``progress_bar`` keyword option was added to `~photutils.segmentation.SourceCatalog` to enable progress bars when calculating some properties (e.g., ``kron_radius``, ``kron_flux``, ``fluxfrac_radius``, ``circular_photometry``, ``centroid_win``, ``centroid_quad``). An option to enable progress bars during PSF fitting was added. To enable it, set ``progress_bar=True`` when calling the PSF-fitting object on your data. The progress bar tracks progress over the star groups. The progress bars require installation of the `tqdm `_ optional dependency. New subshape keyword in PSF fitting =================================== A new ``subshape`` keyword was added to the PSF-fitting classes to define the shape over which the PSF is subtracted when computing the residual image. Previously, the PSF-subtraction region was always defined by the ``fitshape`` keyword. By default (and for backwards compatibility), ``subshape`` is set to `None`, which means the ``fitshape`` value will be used. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/whats_new/1.7.rst0000644000175100001660000000400514755160622017146 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.7? **************************** Here we highlight some of the new functionality of the 1.7 release. In addition to these major changes, Photutils 1.7 includes a large number of smaller improvements and bug fixes, which are described in the full :ref:`changelog`. New Profiles Subpackage ======================= A new :ref:`profile subpackage ` was added containing tools for computing radial profiles and curves of growth: * `~photutils.profiles.RadialProfile` * `~photutils.profiles.CurveOfGrowth` Converting ``SegmentationImage`` segments to polygons ===================================================== The ``SegmentationImage`` class now has a ``polygons`` attribute, which returns a list of `Shapely`_ polygons representing each source segment. It also now has a ``to_patches`` and a ``plot_patches`` method, which returns or plots, respectively, a list of `matplotlib.patches.Polygon` objects. These features require that both the `Rasterio`_ and `Shapely`_ optional dependencies are installed. ``ApertureStats`` local background ================================== The `~photutils.aperture.ApertureStats` ``local_bkg`` keyword can now be input as a scalar value, which will be broadcast for apertures with multiple positions. This can be useful to avoid loading large memory-mapped images into memory if the background level is constant. Performance Improvements ======================== A number of significant performance improvements have been made: * :func:`~photutils.aperture.aperture_photometry` and :meth:`~photutils.aperture.PixelAperture.do_photometry` * :meth:`~photutils.aperture.PixelAperture.area_overlap` * :class:`~photutils.psf.GriddedPSFModel` * :meth:`~photutils.segmentation.SegmentationImage.make_source_mask` Other changes ============= Please see the :ref:`changelog` for the complete list of changes. .. _Rasterio: https://rasterio.readthedocs.io/en/stable/ .. _Shapely: https://shapely.readthedocs.io/en/stable/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/whats_new/1.8.rst0000644000175100001660000000204114755160622017145 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.8? **************************** Here we highlight some of the new functionality of the 1.8 release. In addition to these major changes, Photutils 1.8 includes a large number of smaller improvements and bug fixes, which are described in the full :ref:`changelog`. API changes to ``RadialProfile`` and ``CurveOfGrowth`` ------------------------------------------------------ The API for defining the radial bins for the `~photutils.profiles.RadialProfile` and `~photutils.profiles.CurveOfGrowth` classes was changed. The new API provides more flexibility by allowing the user full control of the radial bins, including non-uniform radial spacing. Unfortunately, due to the nature of this change, it was not possible to have a deprecation phase for the inputs to these classes. Because the changes are not backwards-compatible, one will need to update how these classes are created. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/whats_new/1.9.rst0000644000175100001660000000647014755160622017160 0ustar00runnerdocker.. doctest-skip-all **************************** What's New in Photutils 1.9? **************************** Here we highlight some of the new functionality of the 1.9 release. In addition to these major changes, Photutils 1.9 includes a large number of smaller improvements and bug fixes, which are described in the full :ref:`changelog`. Improved PSF Photometry classes ------------------------------- The `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry` classes were added to perform PSF photometry. They represent a complete rewrite of the previous ``~photutils.psf.BasicPSFPhotometry`` and ``~photutils.psf.IterativelySubtractedPSFPhotometry`` classes, but have a similar API and functionality. The new classes are more flexible and significantly faster than the previous classes. The new classes also allow the input of error arrays, which will be used as weights in the fitting. When using astropy 5.3+, these errors will also be propagated to the model fit parameters. Some other features of the new classes include: * The source grouper is optional * The output table is always in source ID order (not group ID order) * Added two quality-of-fit metrics to the output table * Added more information (columns and metadata) in the output table, including a flags column * Fit warnings are not emitted for each source. A single warning is emitted at the end of fitting. * Fixes issues with source masking * The initial parameters table is more flexible for the x, y, and flux column names * Supports `~astropy.nddata.NDData` objects * Supports units * Allows access to the fitter details (e.g., fit info, parameter covariances) * Allows access to the finder results * Adds a local background subtraction option The old PSF photometry classes (``~photutils.psf.BasicPSFPhotometry``, ``~photutils.psf.IterativelySubtractedPSFPhotometry``, and ``~photutils.psf.DAOPhotPSFPhotometry``) are still available in this release, but are deprecated. They will be removed in a future release. New ``SourceGrouper`` class --------------------------- The `~photutils.psf.SourceGrouper` class was added to group sources using hierarchical agglomerative clustering with a distance criterion. This class is used by the new `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry` classes. The old source grouping classes (``~photutils.psf.DAOGroup`` and ``~photutils.psf.DBSCANGroup``) are still available in this release, but are deprecated. They will be removed in a future release. New ``LocalBackground`` class ----------------------------- The `~photutils.background.LocalBackground` class was added to compute a local background using a circular annulus aperture. Reading and plotting Gridded PSF Models --------------------------------------- A read method was added to the `~photutils.psf.GriddedPSFModel` class to read STDPSF FITS files containing grids of ePSF models. The `~photutils.psf.GriddedPSFModel` class also has a new ``plot_grid`` method to plot the ePSF models. Similarly, the `~photutils.psf.STDPSFGrid` class was added to read STDPSF FITS files. This class can read and plot multi-detector ePSF grids. Note that it is merely a container for STDPDF files. It cannot be used as a PSF model in the photometry classes. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/whats_new/2.0.rst0000644000175100001660000003043114755160622017142 0ustar00runnerdocker.. 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`. Imports ======= Importing tools from all subpackages now requires including the subpackage name. These deprecations were introduced in version 1.6.0 (2022-12-09). Also, PSF matching tools must now be imported from ``photutils.psf.matching`` instead of ``photutils.psf`` For example, this is no longer allowed: ``from photutils import CircularAperture``. Instead use this: ``from photutils.aperture import CircularAperture``. SciPy is now a required dependency ================================== `SciPy `_ is now a required dependency for Photutils, instead of an optional dependency. This change was made because most of the subpackages in Photutils require SciPy for functionality. Aperture photometry tools now accept Region objects =================================================== The `~photutils.aperture.aperture_photometry` and `~photutils.aperture.ApertureStats` tools now accept supported ``regions.Region`` objects from the `Astropy regions package `_, i.e., those corresponding to circular, elliptical, and rectangular apertures. A new `~photutils.aperture.region_to_aperture` convenience function also has been added to convert supported ``regions.Region`` objects to ``Aperture`` objects. With these changes, the `Astropy regions package `_ is now an optional dependency for Photutils. It will need to be installed to use the above functionality. Background2D improved performance and changes ============================================= The `~photutils.background.Background2D` class has been refactored to significantly reduce its memory usage. In some cases, it is also significantly faster. To reduce memory usage, ``Background2D`` no longer keeps a cached copy of the returned ``background`` and ``background_rms`` properties. Assign these properties to variables if you need to use them multiple times, otherwise they will need to be recomputed. The ``background``, ``background_rms``, ``background_mesh``, and ``background_rms_mesh`` properties now have the same ``dtype`` as the input data. Two new properties were also added to the ``Background2D`` class, ``npixels_mesh`` and ``npixels_map``, that give a 2D array of the number of pixels used to compute the statistics in the low-resolution grid and the resized image, respectively. Additionally, the ``background_mesh`` and ``background_rms_mesh`` properties will have units if the input data has units. As part of these changes, the ``edge_method`` keyword is now deprecated and will be removed in a future version. When removed, the ``edge_method`` will always be ``'pad'``. The ``'crop'`` option has been strongly discouraged for some time now. Its usage creates a undesirable scaling of the low-resolution maps that leads to incorrect results. The ``background_mesh_masked``, ``background_rms_mesh_masked``, and ``mesh_nmasked`` properties are now deprecated and will be removed in a future version. The ``data``, ``mask``, ``total_mask``, ``nboxes``, ``box_npixels``, and ``nboxes_tot`` class attributes have been removed. Finally, the `~photutils.background.BkgZoomInterpolator` ``grid_mode`` keyword is now deprecated. When ``grid_mode`` is eventually removed, the `True` option will always be used. For zooming 2D images, this keyword should be set to `True`, which makes zoom's behavior consistent with `scipy.ndimage.map_coordinates` and `skimage.transform.resize`. The `False` option was provided only for backwards-compatibility. GriddedPSFModel improved performance ==================================== The `~photutils.psf.GriddedPSFModel` class has been refactored to significantly improve its performance. In typical PSF photometry use cases, it is now about 4 times faster than previous versions. New PSF Model classes ====================== New models were added to the ``photutils.psf`` module. These include: - `~photutils.psf.ImagePSF`: a general class for image-based PSF models that allows for intensity scaling and translations. - `~photutils.psf.GaussianPSF`: a general 2D Gaussian PSF model parameterized in terms of the position, total flux, and full width at half maximum (FWHM) along the x and y axes. Rotation can also be included. - `~photutils.psf.CircularGaussianPSF`: a circular 2D Gaussian PSF model parameterized in terms of the position, total flux, and FWHM. - `~photutils.psf.GaussianPRF`: a general 2D Gaussian PSF model parameterized in terms of the position, total flux, and FWHM along the x and y axes. Rotation can also be included. - `~photutils.psf.CircularGaussianPRF`: a circular 2D Gaussian PRF model parameterized in terms of the position, total flux, and FWHM. - `~photutils.psf.CircularGaussianSigmaPRF`: a circular 2D Gaussian PRF model parameterized in terms of the position, total flux, and sigma (standard deviation). - `~photutils.psf.MoffatPSF`: a 2D Moffat PSF model parameterized in terms of the position, total flux, :math:`\alpha`, and :math:`\beta` parameters. - `~photutils.psf.AiryDiskPSF`: a 2D Airy disk PSF model parameterized in terms of the position, total flux, and radius of the first dark ring. Note there are two types of defined models, PSF and PRF models. The PSF models are evaluated by sampling the analytic function at the input (x, y) coordinates. The PRF models are evaluated by integrating the analytic function over the pixel areas. The existing ``IntegratedGaussianPRF`` model is now deprecated and will be removed in a future version. It has been replaced by the `~photutils.psf.CircularGaussianSigmaPRF` model. The existing ``FittableImageModel`` and ``EPSFModel`` classes are now deprecated and will be removed in a future version. They have been replaced by the new `~photutils.psf.ImagePSF` class. Legacy ``LevMarLSQFitter`` no longer used ========================================= The default Astropy fitter for ``PSFPhotometry``, ``IterativePSFPhotometry``, and ``EPSFFitter`` was changed from ``LevMarLSQFitter`` to ``TRFLSQFitter``. ``LevMarLSQFitter`` uses the Levenberg-Marquardt algorithm via the SciPy legacy function ``scipy.optimize.leastsq``, which is no longer recommended. This fitter supports parameter bounds using an unsophisticated min/max condition where parameters that are out of bounds are simply reset to the min or max of the bounds during each step. This can cause parameters to stick to one of the bounds during the fitting process if the parameter gets close to the bound. If needed, this fitter can still be used by explicitly setting the fitter in the ``PSFPhotometry``, ``IterativePSFPhotometry``, and ``EPSFFitter`` classes. The fitter used in ``RadialProfile`` to fit the profile with a Gaussian was also changed from ``LevMarLSQFitter`` to ``TRFLSQFitter``. The fitter used in ``centroid_1dg`` and ``centroid_2dg`` was also changed from ``LevMarLSQFitter`` to ``TRFLSQFitter``. For more information about Astropy's non-linear fitters, see :ref:`astropy:modeling-getting-started-nonlinear-notes`. Breaking API Change for PSF Photometry residual/model images ============================================================ The ``sub_shape`` keyword in `~photutils.psf.IterativePSFPhotometry` now defaults to using the model bounding box to define the shape. This is a change from the previous behavior where the default shape was set to ``fit_shape``. In general, one should want the subtraction shape to cover a large portion of the model image, which the bounding box does. If one wants to use a different shape, then the ``sub_shape`` keyword can be explicitly set. If the PSF model does not have a bounding box attribute, then the ``sub_shape`` keyword must be set to define the subtraction shape. Similarly, ``psf_shape`` is now an optional keyword in the ``make_model_image`` and ``make_residual_image`` methods of `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry`. The value defaults to using the model bounding box to define the shape and is required only if the PSF model does not have a bounding box attribute. In general, one should want the model and residual images to be constructed using a large portion of model image, which the bounding box does. If one wants to use a different shape, then the ``psf_shape`` keyword can be explicitly set. Bounding model fits in PSF Photometry ===================================== A new ``xy_bounds`` keyword was added to `~photutils.psf.PSFPhotometry` and `~photutils.psf.IterativePSFPhotometry` to allow one to bound the x and y model parameters during the fitting. This can be used to prevent the fit values from wandering too far from the initial parameter guesses. New FWHM estimation tool ======================== A new `~photutils.psf.fit_fwhm` convenience function was added to estimate the FWHM of one or more sources in an image by fitting a circular 2D Gaussian PRF model using the PSF photometry tools. Similarly, a new `~photutils.psf.fit_2dgaussian` convenience function was added to fit a circular 2D Gaussian PRF to one or more sources in an image. Segmentation Image data type ============================ The `~photutils.segmentation.detect_sources` and `~photutils.segmentation.deblend_sources` functions and `~photutils.segmentation.SourceFinder` class now return a ``SegmentationImage`` instance whose data dtype is ``np.int32`` instead of ``int`` (``int64``) unless more than (2**32 - 1) labels are needed. Also, the ``relabel_consecutive``, ``resassign_label(s)``, ``keep_label(s)``, ``remove_label(s)``, ``remove_border_labels``, and ``remove_masked_labels`` methods now keep the original dtype of the segmentation image instead of always changing it to ``int`` (``int64``). Improved performance for source deblending ========================================== Performance improvements and significant reductions in memory usage were made for source deblending, especially for large sources and/or large ``nlevels`` values. The memory usage is now mostly independent of the number of ``nlevels``, and the memory usage will be significantly reduced for large sources. This affects the `~photutils.segmentation.deblend_sources` function and the `~photutils.segmentation.SourceFinder` class. Additionally, the accuracy of the deblending progress bar is now improved when using multiprocessing. The progress bar now also displays the ID label number of either the current source being deblended (serial) or the last source that was deblended (multiprocessing). DAOStarFinder flux and mag changes ================================== The `~photutils.detection.DAOStarFinder` ``flux`` and ``mag`` columns were changed to give sensible values. Previously, the ``flux`` value was defined by the original DAOFIND algorithm as a measure of the intensity ratio of the amplitude of the best fitting Gaussian function at the object position to the detection threshold. Over the years, this has led to a lot of (understandable) confusion. The new ``flux`` column now gives the sum of data values within the kernel footprint. A ``daofind_mag`` column was added for comparison to the original IRAF DAOFIND algorithm. DAOStarFinder and IRAFStarFinder sky keyword removed ==================================================== The deprecated ``sky`` keyword in `~photutils.detection.DAOStarFinder` and `~photutils.detection.IRAFStarFinder` has been removed. Also, there will no longer be a ``sky`` column in the `~photutils.detection.DAOStarFinder` output table. As documented, the input data is assumed to be background-subtracted. Quantity arrays in Centroids ============================ ``Quantity`` arrays can now be input to `~photutils.centroids.centroid_1dg` and `~photutils.centroids.centroid_2dg`. New Sphinx Theme ================ The documentation now uses the `PyData Sphinx `_ theme, which is a modern, responsive theme that is easy to read and navigate. Other changes ============= Please see the :ref:`changelog` for the complete list of changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/whats_new/2.1.rst0000644000175100001660000000576514755160622017157 0ustar00runnerdocker.. 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`. 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 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 columns 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. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/whats_new/2.2.rst0000644000175100001660000000404214755160622017143 0ustar00runnerdocker.. 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`. Converting Aperture Objects to Region Objects --------------------------------------------- A new `~photutils.aperture.aperture_to_region` function was added to convert an `~photutils.aperture.Aperture` object to a `regions.Region` or `regions.Regions` object. Because a `regions.Region` object can only have one position, a `regions.Regions` object will be returned if the input aperture has more than one position. Otherwise, a `regions.Region` object will be returned. The :meth:`regions.Region.write` and :meth:`regions.Regions.write` methods can be used to write the region(s) to a file. Segmentation Image Outlines as Regions Objects ---------------------------------------------- A new :meth:`~photutils.segmentation.SegmentationImage.to_regions` method was added to convert the outlines of the source segments to a `regions.Regions` object. The `regions.Regions` object contains a list of `regions.PolygonPixelRegion` objects, one for each source segment. The `regions.Regions` object can be written to a file using the :meth:`regions.Region.write` method. Raw Radial Profile ------------------ New ``data_radius`` and ``data_profile`` attributes were added to the `~photutils.profiles.RadialProfile` class for calculating the raw radial profile. These attributes return the radii and values of the data points within the maximum radius defined by the input radii. Pixel-based Aperture ``theta`` Units ------------------------------------ The ``theta`` attribute of `~photutils.aperture.EllipticalAperture`, `~photutils.aperture.EllipticalAnnulus`, `~photutils.aperture.RectangularAperture`, and `~photutils.aperture.RectangularAnnulus` apertures is now always returned as an angular `~astropy.units.Quantity` object. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/docs/whats_new/index.rst0000644000175100001660000000110414755160622017745 0ustar00runnerdocker********** What's New ********** .. toctree:: :maxdepth: 1 2.2.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 2.1.rst 2.0.rst 1.13.rst 1.12.rst 1.11.rst 1.10.rst 1.9.rst 1.8.rst 1.7.rst 1.6.rst 1.5.rst 1.4.rst 1.3.rst 1.2.rst 1.1.rst ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6879265 photutils-2.2.0/photutils/0000755000175100001660000000000014755160634015217 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/CITATION.rst0000644000175100001660000000451714755160622017167 0ustar00runnerdockerCiting Photutils ---------------- If you use Photutils for a project that leads to a publication, whether directly or as a dependency of another package, please include the following acknowledgment: .. code-block:: text This research made use of Photutils, an Astropy package for detection and photometry of astronomical sources (Bradley et al. ). where (Bradley et al. ) is a citation to the `Zenodo record `_ of the Photutils version that was used. We also encourage citations in the main text wherever appropriate. BibTex files for all Photutils versions can be found at https://doi.org/10.5281/zenodo.596036. For example, for Photutils v2.0.2, one should cite Bradley et al. 2024 with the BibTeX entry (https://zenodo.org/records/13989456/export/bibtex): .. code-block:: text @software{larry_bradley_2024_13989456, author = {Larry Bradley and Brigitta Sip{\H o}cz and Thomas Robitaille and Erik Tollerud and Z\`e Vin{\'{\i}}cius and Christoph Deil and Kyle Barbary and Tom J Wilson and Ivo Busko and Axel Donath and Hans Moritz G{\"u}nther and Mihai Cara and P. L. Lim and Sebastian Me{\ss}linger and Simon Conseil and Zach Burnett and Azalee Bostroem and Michael Droettboom and E. M. Bray and Lars Andersen Bratholm and Adam Ginsburg and William Jamieson and Geert Barentsen and Matt Craig and Brett M. Morris and Marshall Perrin and Shivangee Rathi and Sergio Pascual and Iskren Y. Georgiev}, title = {astropy/photutils: 2.0.2}, month = oct, year = 2024, publisher = {Zenodo}, version = {2.0.2}, doi = {10.5281/zenodo.13989456}, url = {https://doi.org/10.5281/zenodo.13989456}, } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/__init__.py0000644000175100001660000000152214755160622017325 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Photutils is an Astropy affiliated package to provide tools for detecting and performing photometry of astronomical sources. It also has tools for background estimation, ePSF building, PSF matching, radial profiles, centroiding, and morphological measurements. """ try: from .version import version as __version__ except ImportError: __version__ = '' # Set the bibtex entry to the article referenced in CITATION.rst. def _get_bibtex(): import os citation_file = os.path.join(os.path.dirname(__file__), 'CITATION.rst') with open(citation_file) as citation: refs = citation.read().split('@software')[1:] if len(refs) == 0: return '' return f'@software{refs[0]}' __citation__ = __bibtex__ = _get_bibtex() del _get_bibtex ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907483.0 photutils-2.2.0/photutils/_compiler.c0000644000175100001660000000524014755160633017334 0ustar00runnerdocker#include /*************************************************************************** * Macros for determining the compiler version. * * These are borrowed from boost, and majorly abridged to include only * the compilers we care about. ***************************************************************************/ #define STRINGIZE(X) DO_STRINGIZE(X) #define DO_STRINGIZE(X) #X #if defined __clang__ /* Clang C++ emulates GCC, so it has to appear early. */ # define COMPILER "Clang version " __clang_version__ #elif defined(__INTEL_COMPILER) || defined(__ICL) || defined(__ICC) || defined(__ECC) /* Intel */ # if defined(__INTEL_COMPILER) # define INTEL_VERSION __INTEL_COMPILER # elif defined(__ICL) # define INTEL_VERSION __ICL # elif defined(__ICC) # define INTEL_VERSION __ICC # elif defined(__ECC) # define INTEL_VERSION __ECC # endif # define COMPILER "Intel C compiler version " STRINGIZE(INTEL_VERSION) #elif defined(__GNUC__) /* gcc */ # define COMPILER "GCC version " __VERSION__ #elif defined(__SUNPRO_CC) /* Sun Workshop Compiler */ # define COMPILER "Sun compiler version " STRINGIZE(__SUNPRO_CC) #elif defined(_MSC_VER) /* Microsoft Visual C/C++ Must be last since other compilers define _MSC_VER for compatibility as well */ # if _MSC_VER < 1200 # define COMPILER_VERSION 5.0 # elif _MSC_VER < 1300 # define COMPILER_VERSION 6.0 # elif _MSC_VER == 1300 # define COMPILER_VERSION 7.0 # elif _MSC_VER == 1310 # define COMPILER_VERSION 7.1 # elif _MSC_VER == 1400 # define COMPILER_VERSION 8.0 # elif _MSC_VER == 1500 # define COMPILER_VERSION 9.0 # elif _MSC_VER == 1600 # define COMPILER_VERSION 10.0 # else # define COMPILER_VERSION _MSC_VER # endif # define COMPILER "Microsoft Visual C++ version " STRINGIZE(COMPILER_VERSION) #else /* Fallback */ # define COMPILER "Unknown compiler" #endif /*************************************************************************** * Module-level ***************************************************************************/ struct module_state { /* The Sun compiler can't handle empty structs */ #if defined(__SUNPRO_C) || defined(_MSC_VER) int _dummy; #endif }; static struct PyModuleDef moduledef = { PyModuleDef_HEAD_INIT, "compiler_version", NULL, sizeof(struct module_state), NULL, NULL, NULL, NULL, NULL }; #define INITERROR return NULL PyMODINIT_FUNC PyInit_compiler_version(void) { PyObject* m; m = PyModule_Create(&moduledef); if (m == NULL) INITERROR; PyModule_AddStringConstant(m, "compiler", COMPILER); return m; } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6909266 photutils-2.2.0/photutils/aperture/0000755000175100001660000000000014755160634017046 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/__init__.py0000644000175100001660000000101714755160622021153 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools to perform aperture photometry. """ from .bounding_box import * # noqa: F401, F403 from .circle import * # noqa: F401, F403 from .converters import * # noqa: F401, F403 from .core import * # noqa: F401, F403 from .ellipse import * # noqa: F401, F403 from .mask import * # noqa: F401, F403 from .photometry import * # noqa: F401, F403 from .rectangle import * # noqa: F401, F403 from .stats import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/attributes.py0000644000175100001660000001460214755160622021606 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines descriptor classes for aperture attribute validation. """ import astropy.units as u import numpy as np from astropy.coordinates import SkyCoord __all__ = [ 'ApertureAttribute', 'PixelPositions', 'PositiveScalar', 'ScalarAngle', 'ScalarAngleOrValue', 'SkyCoordPositions', ] class ApertureAttribute: """ Base descriptor class for aperture attribute validation. Parameters ---------- doc : str, optional The description string for the attribute. """ def __init__(self, doc=''): self.__doc__ = doc self.name = '' def __set_name__(self, owner, name): self.name = name def __get__(self, instance, owner): if instance is None: return self # pragma: no cover return instance.__dict__[self.name] def __set__(self, instance, value): self._validate(value) if not isinstance(value, (u.Quantity, SkyCoord)): value = float(value) # no need to reset if not already in the instance dict if self.name in instance.__dict__: self._reset_lazyproperties(instance) instance.__dict__[self.name] = value def _reset_lazyproperties(self, instance): # reset lazyproperties (if they exist) for aperture # parameter changes try: for key in instance._lazyproperties: instance.__dict__.pop(key, None) except AttributeError: pass def __delete__(self, instance): del instance.__dict__[self.name] # pragma: no cover def _validate(self, value): """ Validate the attribute value. An exception is raised if the value is invalid. """ raise NotImplementedError # pragma: no cover class PixelPositions(ApertureAttribute): """ Validate and set positions for pixel-based apertures. Pixel positions are converted to a 2D `~numpy.ndarray`. """ def __set__(self, instance, value): # this is needed for zip (e.g., positions = zip(xpos, ypos)) if isinstance(value, zip): value = tuple(value) value = self._validate(value) # np.ndarray # no need to reset if not already in the instance dict if self.name in instance.__dict__: self._reset_lazyproperties(instance) instance.__dict__[self.name] = value def _validate(self, value): try: value = np.asanyarray(value).astype(float) # np.ndarray except TypeError as exc: # value is a zip object containing Quantity objects raise TypeError(f'{self.name!r} must not be a Quantity') from exc if isinstance(value, u.Quantity): raise TypeError(f'{self.name!r} must not be a Quantity') if np.any(~np.isfinite(value)): raise ValueError(f'{self.name!r} must not contain any non-finite ' '(e.g., NaN or inf) positions') value_2d = np.atleast_2d(value) if value_2d.ndim > 2 or value_2d.shape[1] != 2: raise ValueError(f'{self.name!r} must be a (x, y) pixel position ' 'or a list or array of (x, y) pixel positions, ' 'e.g., [(x1, y1), (x2, y2), (x3, y3)]') return value class SkyCoordPositions(ApertureAttribute): """ Check that value is a `~astropy.coordinates.SkyCoord`. """ def _validate(self, value): if not isinstance(value, SkyCoord): raise TypeError(f'{self.name!r} must be a SkyCoord instance') class PositiveScalar(ApertureAttribute): """ Check that value is a strictly positive (> 0) scalar. """ def _validate(self, value): if not np.isscalar(value) or value <= 0: raise ValueError(f'{self.name!r} must be a positive scalar') class ScalarAngle(ApertureAttribute): """ Check that value is a scalar angle, either as a `~astropy.coordinates.Angle` or `~astropy.units.Quantity` with angular units. """ def _validate(self, value): if isinstance(value, u.Quantity): if not value.isscalar: raise ValueError(f'{self.name!r} must be a scalar') if not value.unit.physical_type == 'angle': raise ValueError(f'{self.name!r} must have angular units') else: raise TypeError(f'{self.name!r} must be a scalar angle') class PositiveScalarAngle(ApertureAttribute): """ Check that value is a positive scalar angle, either as a `~astropy.coordinates.Angle` or `~astropy.units.Quantity` with angular units. """ def _validate(self, value): if value <= 0: raise ValueError(f'{self.name!r} must be greater than zero') if isinstance(value, u.Quantity): if not value.isscalar: raise ValueError(f'{self.name!r} must be a scalar') if not value.unit.physical_type == 'angle': raise ValueError(f'{self.name!r} must have angular units') else: raise TypeError(f'{self.name!r} must be a scalar angle') class ScalarAngleOrValue(ApertureAttribute): """ Check that value is a scalar angle, either as a `~astropy.coordinates.Angle` or `~astropy.units.Quantity` with angular units, or a scalar float. The value is always output as a `~astropy.units.Quantity` with angular units. If the value is not a `~astropy.units.Quantity`, it is assumed to be in radians. """ def __set__(self, instance, value): self._validate(value) # no need to reset if not already in the instance dict if self.name in instance.__dict__: self._reset_lazyproperties(instance) # if theta is not a Quantity, it is assumed to be in radians if not isinstance(value, u.Quantity): value <<= u.radian instance.__dict__[self.name] = value def _validate(self, value): if isinstance(value, u.Quantity): if not value.isscalar: raise ValueError(f'{self.name!r} must be a scalar') if not value.unit.physical_type == 'angle': raise ValueError(f'{self.name!r} must have angular units') elif not np.isscalar(value): raise ValueError(f'If not an angle Quantity, {self.name!r} ' 'must be a scalar float in radians') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/bounding_box.py0000644000175100001660000002726114755160622022102 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines a class for a rectangular bounding box. """ import math import numpy as np __all__ = ['BoundingBox'] class BoundingBox: """ A rectangular bounding box in integer (not float) pixel indices. Parameters ---------- ixmin, ixmax, iymin, iymax : int The bounding box pixel indices. Note that the upper values (``iymax`` and ``ixmax``) are exclusive as for normal slices in Python. The lower values (``ixmin`` and ``iymin``) must not be greater than the respective upper values (``ixmax`` and ``iymax``). Examples -------- When constructing a BoundingBox, it's better to use keyword arguments for readability: >>> from photutils.aperture import BoundingBox >>> bbox = BoundingBox(ixmin=1, ixmax=10, iymin=2, iymax=20) >>> bbox BoundingBox(ixmin=1, ixmax=10, iymin=2, iymax=20) Sometimes it's useful to check if two bounding boxes are the same: >>> bbox == BoundingBox(ixmin=1, ixmax=10, iymin=2, iymax=20) True >>> bbox == BoundingBox(ixmin=7, ixmax=10, iymin=2, iymax=20) False The "center" and "shape" attributes can be useful when working with numpy arrays: >>> bbox.center # numpy order: (y, x) (10.5, 5.0) >>> bbox.shape # numpy order: (y, x) (18, 9) The "extent" is useful when plotting the BoundingBox with matplotlib: >>> bbox.extent # matplotlib order: (xmin, xmax, ymin, ymax) (0.5, 9.5, 1.5, 19.5) """ def __init__(self, ixmin, ixmax, iymin, iymax): for value in (ixmin, ixmax, iymin, iymax): if not isinstance(value, (int, np.integer)): raise TypeError('ixmin, ixmax, iymin, and iymax must all be ' 'integers') if ixmin > ixmax: raise ValueError('ixmin must be <= ixmax') if iymin > iymax: raise ValueError('iymin must be <= iymax') self.ixmin = ixmin self.ixmax = ixmax self.iymin = iymin self.iymax = iymax @classmethod def from_float(cls, xmin, xmax, ymin, ymax): """ Return the smallest bounding box that fully contains a given rectangle defined by float coordinate values. Following the pixel index convention, an integer index corresponds to the center of a pixel and the pixel edges span from (index - 0.5) to (index + 0.5). For example, the pixel edge spans of the following pixels are: * pixel 0: from -0.5 to 0.5 * pixel 1: from 0.5 to 1.5 * pixel 2: from 1.5 to 2.5 In addition, because `BoundingBox` upper limits are exclusive (by definition), 1 is added to the upper pixel edges. See examples below. Parameters ---------- xmin, xmax, ymin, ymax : float Float coordinates defining a rectangle. The lower values (``xmin`` and ``ymin``) must not be greater than the respective upper values (``xmax`` and ``ymax``). Returns ------- bbox : `BoundingBox` object The minimal ``BoundingBox`` object fully containing the input rectangle coordinates. Examples -------- >>> from photutils.aperture import BoundingBox >>> BoundingBox.from_float(xmin=1.0, xmax=10.0, ymin=2.0, ymax=20.0) BoundingBox(ixmin=1, ixmax=11, iymin=2, iymax=21) >>> BoundingBox.from_float(xmin=1.4, xmax=10.4, ymin=1.6, ymax=10.6) BoundingBox(ixmin=1, ixmax=11, iymin=2, iymax=12) """ ixmin = math.floor(xmin + 0.5) ixmax = math.ceil(xmax + 0.5) iymin = math.floor(ymin + 0.5) iymax = math.ceil(ymax + 0.5) return cls(ixmin, ixmax, iymin, iymax) def __eq__(self, other): if not isinstance(other, BoundingBox): raise TypeError('Can compare BoundingBox only to another ' 'BoundingBox.') return ((self.ixmin == other.ixmin) and (self.ixmax == other.ixmax) and (self.iymin == other.iymin) and (self.iymax == other.iymax)) def __or__(self, other): return self.union(other) def __and__(self, other): return self.intersection(other) def __repr__(self): return (f'{self.__class__.__name__}(ixmin={self.ixmin}, ' f'ixmax={self.ixmax}, iymin={self.iymin}, ' f'iymax={self.iymax})') @property def center(self): """ The ``(y, x)`` center of the bounding box. """ return (0.5 * (self.iymax - 1 + self.iymin), 0.5 * (self.ixmax - 1 + self.ixmin)) @property def shape(self): """ The ``(ny, nx)`` shape of the bounding box. """ return self.iymax - self.iymin, self.ixmax - self.ixmin def get_overlap_slices(self, shape): """ Get slices for the overlapping part of the bounding box and an 2D array. Parameters ---------- shape : 2-tuple of int The shape of the 2D array. Returns ------- slices_large : tuple of slices or `None` A tuple of slice objects for each axis of the large array, such that ``large_array[slices_large]`` extracts the region of the large array that overlaps with the small array. `None` is returned if there is no overlap of the bounding box with the given image shape. slices_small : tuple of slices or `None` A tuple of slice objects for each axis of an array enclosed by the bounding box such that ``small_array[slices_small]`` extracts the region that is inside the large array. `None` is returned if there is no overlap of the bounding box with the given image shape. """ if len(shape) != 2: raise ValueError('input shape must have 2 elements.') xmin = self.ixmin xmax = self.ixmax ymin = self.iymin ymax = self.iymax if xmin >= shape[1] or ymin >= shape[0] or xmax <= 0 or ymax <= 0: # no overlap of the bounding box with the input shape return None, None slices_large = (slice(max(ymin, 0), min(ymax, shape[0])), slice(max(xmin, 0), min(xmax, shape[1]))) slices_small = (slice(max(-ymin, 0), min(ymax - ymin, shape[0] - ymin)), slice(max(-xmin, 0), min(xmax - xmin, shape[1] - xmin))) return slices_large, slices_small @property def extent(self): """ The extent of the mask, defined as the ``(xmin, xmax, ymin, ymax)`` bounding box from the bottom-left corner of the lower- left pixel to the upper-right corner of the upper-right pixel. The upper edges here are the actual pixel positions of the edges, i.e., they are not "exclusive" indices used for python indexing. This is useful for plotting the bounding box using Matplotlib. """ return (self.ixmin - 0.5, self.ixmax - 0.5, self.iymin - 0.5, self.iymax - 0.5) def as_artist(self, **kwargs): """ Return a `matplotlib.patches.Rectangle` that represents the bounding box. Parameters ---------- **kwargs : dict, 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', interpolation='nearest') 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) def plot(self, ax=None, origin=(0, 0), **kwargs): """ Plot the `BoundingBox` on a matplotlib `~matplotlib.axes.Axes` instance. Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : dict, optional Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- patch : `matplotlib.patches.Patch` The matplotlib patch object for the plotted bounding box. The patch can be used, for example, when adding a plot legend. """ aper = self.to_aperture() return aper.plot(ax=ax, origin=origin, **kwargs)[0] def union(self, other): """ Return a `BoundingBox` representing the union of this `BoundingBox` with another `BoundingBox`. Parameters ---------- other : `BoundingBox` The `BoundingBox` to join with this one. Returns ------- result : `BoundingBox` A `BoundingBox` representing the union of the input `BoundingBox` with this one. """ if not isinstance(other, BoundingBox): raise TypeError('BoundingBox can be joined only with another ' 'BoundingBox.') ixmin = min((self.ixmin, other.ixmin)) ixmax = max((self.ixmax, other.ixmax)) iymin = min((self.iymin, other.iymin)) iymax = max((self.iymax, other.iymax)) return BoundingBox(ixmin=ixmin, ixmax=ixmax, iymin=iymin, iymax=iymax) def intersection(self, other): """ Return a `BoundingBox` representing the intersection of this `BoundingBox` with another `BoundingBox`. Parameters ---------- other : `BoundingBox` The `BoundingBox` to intersect with this one. Returns ------- result : `BoundingBox` A `BoundingBox` representing the intersection of the input `BoundingBox` with this one. """ if not isinstance(other, BoundingBox): raise TypeError('BoundingBox can be intersected only with ' 'another BoundingBox.') ixmin = max(self.ixmin, other.ixmin) ixmax = min(self.ixmax, other.ixmax) iymin = max(self.iymin, other.iymin) iymax = min(self.iymax, other.iymax) if ixmax < ixmin or iymax < iymin: return None return BoundingBox(ixmin=ixmin, ixmax=ixmax, iymin=iymin, iymax=iymax) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/circle.py0000644000175100001660000003663514755160622020673 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines circular and circular-annulus apertures in both pixel and sky coordinates. """ import math from astropy.utils import lazyproperty from photutils.aperture.attributes import (PixelPositions, PositiveScalar, PositiveScalarAngle, SkyCoordPositions) from photutils.aperture.core import PixelAperture, SkyAperture from photutils.aperture.mask import ApertureMask from photutils.geometry import circular_overlap_grid __all__ = [ 'CircularAnnulus', 'CircularAperture', 'CircularMaskMixin', 'SkyCircularAnnulus', 'SkyCircularAperture', ] class CircularMaskMixin: """ Mixin class to create masks for circular and circular-annulus aperture objects. """ def to_mask(self, method='exact', subpixels=5): """ Return a mask for the aperture. Parameters ---------- method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid. Not all options are available for all aperture types. Note that the more precise methods are generally slower. The following methods are available: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. Returns ------- mask : `~photutils.aperture.ApertureMask` or list of \ `~photutils.aperture.ApertureMask` A mask for the aperture. If the aperture is scalar then a single `~photutils.aperture.ApertureMask` is returned, otherwise a list of `~photutils.aperture.ApertureMask` is returned. """ use_exact, subpixels = self._translate_mask_mode(method, subpixels) if hasattr(self, 'r'): radius = self.r elif hasattr(self, 'r_out'): # annulus radius = self.r_out else: raise ValueError('Cannot determine the aperture radius.') masks = [] for bbox, edges in zip(self._bbox, self._centered_edges, strict=True): ny, nx = bbox.shape mask = circular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, radius, use_exact, subpixels) # subtract the inner circle for an annulus if hasattr(self, 'r_in'): mask -= circular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.r_in, use_exact, subpixels) masks.append(ApertureMask(mask, bbox)) if self.isscalar: return masks[0] return masks class CircularAperture(CircularMaskMixin, PixelAperture): """ A circular aperture defined in pixel coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : array_like The pixel coordinates of the aperture center(s) in one of the following formats: * single ``(x, y)`` pair as a tuple, list, or `~numpy.ndarray` * tuple, list, or `~numpy.ndarray` of ``(x, y)`` pairs r : float The radius of the circle in pixels. Raises ------ ValueError : `ValueError` If the input radius, ``r``, is negative. Examples -------- >>> from photutils.aperture import CircularAperture >>> aper = CircularAperture([10.0, 20.0], 3.0) >>> aper = CircularAperture((10.0, 20.0), 3.0) >>> pos1 = (10.0, 20.0) # (x, y) >>> pos2 = (30.0, 40.0) >>> pos3 = (50.0, 60.0) >>> aper = CircularAperture([pos1, pos2, pos3], 3.0) >>> aper = CircularAperture((pos1, pos2, pos3), 3.0) """ _params = ('positions', 'r') positions = PixelPositions('The center pixel position(s).') r = PositiveScalar('The radius in pixels.') def __init__(self, positions, r): self.positions = positions self.r = r @lazyproperty def _xy_extents(self): return self.r, self.r @lazyproperty def area(self): """ The exact geometric area of the aperture shape. """ return math.pi * self.r**2 def _to_patch(self, origin=(0, 0), **kwargs): """ Return a `~matplotlib.patches.Patch` for the aperture. Parameters ---------- origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : dict, optional Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- patch : `~matplotlib.patches.Patch` or list of \ `~matplotlib.patches.Patch` A patch for the aperture. If the aperture is scalar then a single `~matplotlib.patches.Patch` is returned, otherwise a list of `~matplotlib.patches.Patch` is returned. """ import matplotlib.patches as mpatches xy_positions, patch_kwargs = self._define_patch_params(origin=origin, **kwargs) patches = [mpatches.Circle(xy_position, self.r, **patch_kwargs) for xy_position in xy_positions] if self.isscalar: return patches[0] return patches def to_mask(self, method='exact', subpixels=5): return CircularMaskMixin.to_mask(self, method=method, subpixels=subpixels) def to_sky(self, wcs): """ Convert the aperture to a `SkyCircularAperture` object defined in celestial coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `SkyCircularAperture` object A `SkyCircularAperture` object. """ return SkyCircularAperture(**self._to_sky_params(wcs)) class CircularAnnulus(CircularMaskMixin, PixelAperture): """ A circular annulus aperture defined in pixel coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : array_like The pixel coordinates of the aperture center(s) in one of the following formats: * single ``(x, y)`` pair as a tuple, list, or `~numpy.ndarray` * tuple, list, or `~numpy.ndarray` of ``(x, y)`` pairs r_in : float The inner radius of the circular annulus in pixels. r_out : float The outer radius of the circular annulus in pixels. Raises ------ ValueError : `ValueError` If inner radius (``r_in``) is greater than outer radius (``r_out``). ValueError : `ValueError` If inner radius (``r_in``) is negative. Examples -------- >>> from photutils.aperture import CircularAnnulus >>> aper = CircularAnnulus([10.0, 20.0], 3.0, 5.0) >>> aper = CircularAnnulus((10.0, 20.0), 3.0, 5.0) >>> pos1 = (10.0, 20.0) # (x, y) >>> pos2 = (30.0, 40.0) >>> pos3 = (50.0, 60.0) >>> aper = CircularAnnulus([pos1, pos2, pos3], 3.0, 5.0) >>> aper = CircularAnnulus((pos1, pos2, pos3), 3.0, 5.0) """ _params = ('positions', 'r_in', 'r_out') positions = PixelPositions('The center pixel position(s).') r_in = PositiveScalar('The inner radius in pixels.') r_out = PositiveScalar('The outer radius in pixels.') def __init__(self, positions, r_in, r_out): if not r_out > r_in: raise ValueError('r_out must be greater than r_in') self.positions = positions self.r_in = r_in self.r_out = r_out @lazyproperty def _xy_extents(self): return self.r_out, self.r_out @lazyproperty def area(self): """ The exact geometric area of the aperture shape. """ return math.pi * (self.r_out**2 - self.r_in**2) def _to_patch(self, origin=(0, 0), **kwargs): """ Return a `~matplotlib.patches.Patch` for the aperture. Parameters ---------- origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : dict, optional Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- patch : `~matplotlib.patches.Patch` or list of \ `~matplotlib.patches.Patch` A patch for the aperture. If the aperture is scalar then a single `~matplotlib.patches.Patch` is returned, otherwise a list of `~matplotlib.patches.Patch` is returned. """ import matplotlib.patches as mpatches xy_positions, patch_kwargs = self._define_patch_params(origin=origin, **kwargs) patches = [] for xy_position in xy_positions: patch_inner = mpatches.Circle(xy_position, self.r_in) patch_outer = mpatches.Circle(xy_position, self.r_out) path = self._make_annulus_path(patch_inner, patch_outer) patches.append(mpatches.PathPatch(path, **patch_kwargs)) if self.isscalar: return patches[0] return patches def to_mask(self, method='exact', subpixels=5): return CircularMaskMixin.to_mask(self, method=method, subpixels=subpixels) def to_sky(self, wcs): """ Convert the aperture to a `SkyCircularAnnulus` object defined in celestial coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `SkyCircularAnnulus` object A `SkyCircularAnnulus` object. """ return SkyCircularAnnulus(**self._to_sky_params(wcs)) class SkyCircularAperture(SkyAperture): """ A circular aperture defined in sky coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The celestial coordinates of the aperture center(s). This can be either scalar coordinates or an array of coordinates. r : scalar `~astropy.units.Quantity` The radius of the circle in angular units. Examples -------- >>> from astropy.coordinates import SkyCoord >>> import astropy.units as u >>> from photutils.aperture import SkyCircularAperture >>> positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') >>> aper = SkyCircularAperture(positions, 0.5*u.arcsec) """ _params = ('positions', 'r') positions = SkyCoordPositions('The center position(s) in sky coordinates.') r = PositiveScalarAngle('The radius in angular units.') def __init__(self, positions, r): self.positions = positions self.r = r def to_pixel(self, wcs): """ Convert the aperture to a `CircularAperture` object defined in pixel coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `CircularAperture` object A `CircularAperture` object. """ return CircularAperture(**self._to_pixel_params(wcs)) class SkyCircularAnnulus(SkyAperture): """ A circular annulus aperture defined in sky coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The celestial coordinates of the aperture center(s). This can be either scalar coordinates or an array of coordinates. r_in : scalar `~astropy.units.Quantity` The inner radius of the circular annulus in angular units. r_out : scalar `~astropy.units.Quantity` The outer radius of the circular annulus in angular units. Examples -------- >>> from astropy.coordinates import SkyCoord >>> import astropy.units as u >>> from photutils.aperture import SkyCircularAnnulus >>> positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') >>> aper = SkyCircularAnnulus(positions, 0.5*u.arcsec, 1.0*u.arcsec) """ _params = ('positions', 'r_in', 'r_out') positions = SkyCoordPositions('The center position(s) in sky coordinates.') r_in = PositiveScalarAngle('The inner radius in angular units.') r_out = PositiveScalarAngle('The outer radius in angular units.') def __init__(self, positions, r_in, r_out): if r_in.unit.physical_type != r_out.unit.physical_type: raise ValueError('r_in and r_out should either both be angles ' 'or in pixels.') self.positions = positions self.r_in = r_in self.r_out = r_out def to_pixel(self, wcs): """ Convert the aperture to a `CircularAnnulus` object defined in pixel coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `CircularAnnulus` object A `CircularAnnulus` object. """ return CircularAnnulus(**self._to_pixel_params(wcs)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/converters.py0000644000175100001660000003764114755160622021622 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines tools to convert `regions.Region` objects to Aperture objects. """ import astropy.units as u import numpy as np # prevent circular imports from photutils.aperture.circle import (CircularAnnulus, CircularAperture, SkyCircularAnnulus, SkyCircularAperture) from photutils.aperture.core import Aperture from photutils.aperture.ellipse import (EllipticalAnnulus, EllipticalAperture, SkyEllipticalAnnulus, SkyEllipticalAperture) from photutils.aperture.rectangle import (RectangularAnnulus, RectangularAperture, SkyRectangularAnnulus, SkyRectangularAperture) __all__ = ['aperture_to_region', 'region_to_aperture'] __doctest_requires__ = {'region_to_aperture': ['regions'], 'aperture_to_region': ['regions'], '_scalar_aperture_to_region': ['regions'], '_shapely_polygon_to_region': ['regions', 'shapely']} def region_to_aperture(region): """ Convert a given `regions.Region` object to an `~photutils.aperture.Aperture` object. Parameters ---------- region : `regions.Region` A supported `regions.Region` object. Returns ------- aperture : `~photutils.aperture.Aperture` An equivalent ``photutils`` aperture. Raises ------ `TypeError` The given `regions.Region` object is not supported. Notes ----- The ellipse ``width`` and ``height`` region parameters represent the full extent of the shapes and thus are divided by 2 when converting to elliptical aperture objects, which are defined using the semi-major (``a``) and semi-minor (``b``) axes. The ``width`` and ``height`` parameters are mapped to the the semi-major (``a``) and semi-minor (``b``) axes parameters, respectively, of the elliptical apertures. The region ``angle`` for sky-based regions is defined as the angle of the ``width`` axis relative to WCS longitude axis (PA=90). However, the sky-based apertures define the ``theta`` as the position angle of the semimajor axis relative to the North celestial pole (PA=0). Therefore, for sky-based regions the region ``angle`` is converted to the aperture ``theta`` parameter by subtracting 90 degrees. .. |rarr| unicode:: U+0279E .. RIGHTWARDS ARROW The following `regions.Region` objects are supported, shown with their equivalent `~photutils.aperture.Aperture` object: * `~regions.CirclePixelRegion` |rarr| `~photutils.aperture.CircularAperture` * `~regions.CircleSkyRegion` |rarr| `~photutils.aperture.SkyCircularAperture` * `~regions.EllipsePixelRegion` |rarr| `~photutils.aperture.EllipticalAperture` * `~regions.EllipseSkyRegion` |rarr| `~photutils.aperture.SkyEllipticalAperture` * `~regions.RectanglePixelRegion` |rarr| `~photutils.aperture.RectangularAperture` * `~regions.RectangleSkyRegion` |rarr| `~photutils.aperture.SkyRectangularAperture` * `~regions.CircleAnnulusPixelRegion` |rarr| `~photutils.aperture.CircularAnnulus` * `~regions.CircleAnnulusSkyRegion` |rarr| `~photutils.aperture.SkyCircularAnnulus` * `~regions.EllipseAnnulusPixelRegion` |rarr| `~photutils.aperture.EllipticalAnnulus` * `~regions.EllipseAnnulusSkyRegion` |rarr| `~photutils.aperture.SkyEllipticalAnnulus` * `~regions.RectangleAnnulusPixelRegion` |rarr| `~photutils.aperture.RectangularAnnulus` * `~regions.RectangleAnnulusSkyRegion` |rarr| `~photutils.aperture.SkyRectangularAnnulus` Examples -------- >>> from regions import CirclePixelRegion, PixCoord >>> from photutils.aperture import region_to_aperture >>> region = CirclePixelRegion(center=PixCoord(x=10, y=20), radius=5) >>> aperture = region_to_aperture(region) >>> aperture """ from regions import (CircleAnnulusPixelRegion, CircleAnnulusSkyRegion, CirclePixelRegion, CircleSkyRegion, EllipseAnnulusPixelRegion, EllipseAnnulusSkyRegion, EllipsePixelRegion, EllipseSkyRegion, RectangleAnnulusPixelRegion, RectangleAnnulusSkyRegion, RectanglePixelRegion, RectangleSkyRegion, Region) if not isinstance(region, Region): raise TypeError('Input region must be a Region object') if isinstance(region, CirclePixelRegion): aperture = CircularAperture(region.center.xy, region.radius) elif isinstance(region, CircleSkyRegion): aperture = SkyCircularAperture(region.center, region.radius) elif isinstance(region, EllipsePixelRegion): aperture = EllipticalAperture( region.center.xy, region.width * 0.5, region.height * 0.5, theta=region.angle) elif isinstance(region, EllipseSkyRegion): aperture = SkyEllipticalAperture( region.center, region.width * 0.5, region.height * 0.5, theta=(region.angle - (90 * u.deg))) elif isinstance(region, RectanglePixelRegion): aperture = RectangularAperture( region.center.xy, region.width, region.height, theta=region.angle) elif isinstance(region, RectangleSkyRegion): aperture = SkyRectangularAperture( region.center, region.width, region.height, theta=(region.angle - (90 * u.deg))) elif isinstance(region, CircleAnnulusPixelRegion): aperture = CircularAnnulus( region.center.xy, region.inner_radius, region.outer_radius) elif isinstance(region, CircleAnnulusSkyRegion): aperture = SkyCircularAnnulus( region.center, region.inner_radius, region.outer_radius) elif isinstance(region, EllipseAnnulusPixelRegion): aperture = EllipticalAnnulus( region.center.xy, region.inner_width * 0.5, region.outer_width * 0.5, region.outer_height * 0.5, b_in=region.inner_height * 0.5, theta=region.angle) elif isinstance(region, EllipseAnnulusSkyRegion): aperture = SkyEllipticalAnnulus( region.center, region.inner_width * 0.5, region.outer_width * 0.5, region.outer_height * 0.5, b_in=region.inner_height * 0.5, theta=(region.angle - (90 * u.deg))) elif isinstance(region, RectangleAnnulusPixelRegion): aperture = RectangularAnnulus( region.center.xy, region.inner_width, region.outer_width, region.outer_height, h_in=region.inner_height, theta=region.angle) elif isinstance(region, RectangleAnnulusSkyRegion): aperture = SkyRectangularAnnulus( region.center, region.inner_width, region.outer_width, region.outer_height, h_in=region.inner_height, theta=(region.angle - (90 * u.deg))) else: raise TypeError(f'Cannot convert {region.__class__.__name__!r} to ' 'an Aperture object') return aperture def aperture_to_region(aperture): """ Convert a given `~photutils.aperture.Aperture` object to a `regions.Region` or `regions.Regions` object. Because a `regions.Region` object can only have one position, a `regions.Regions` object will be returned if the input ``aperture`` has more than one position. Otherwise, a `regions.Region` object will be returned. Parameters ---------- aperture : `~photutils.aperture.Aperture` An `~photutils.aperture.Aperture` object to convert. Returns ------- region : `regions.Region` or `regions.Regions` An equivalent `regions.Region` object. If the input ``aperture`` has more than one position then a `regions.Regions` will be returned. Notes ----- The elliptical aperture ``a`` and ``b`` parameters represent the semi-major and semi-minor axes, respectively. The ``a`` and ``b`` parameters are mapped to the ellipse ``width`` and ``height`` region parameters, respectively, by multiplying by 2 because they represent the full extent of the ellipse. The region ``angle`` for sky-based regions is defined as the angle of the ``width`` axis relative to WCS longitude axis (PA=90). However, the sky-based apertures define the ``theta`` as the position angle of the semimajor axis relative to the North celestial pole (PA=0). Therefore, for sky-based apertures the ``theta`` parameter is converted to the region ``angle`` by adding 90 degrees. .. |rarr| unicode:: U+0279E .. RIGHTWARDS ARROW The following `~photutils.aperture.Aperture` objects are supported, shown with their equivalent `regions.Region` object: * `~photutils.aperture.CircularAperture` |rarr| `~regions.CirclePixelRegion` * `~photutils.aperture.SkyCircularAperture` |rarr| `~regions.CircleSkyRegion` * `~photutils.aperture.EllipticalAperture` |rarr| `~regions.EllipsePixelRegion` * `~photutils.aperture.SkyEllipticalAperture` |rarr| `~regions.EllipseSkyRegion` * `~photutils.aperture.RectangularAperture` |rarr| `~regions.RectanglePixelRegion` * `~photutils.aperture.SkyRectangularAperture` |rarr| `~regions.RectangleSkyRegion` * `~photutils.aperture.CircularAnnulus` |rarr| `~regions.CircleAnnulusPixelRegion` * `~photutils.aperture.SkyCircularAnnulus` |rarr| `~regions.CircleAnnulusSkyRegion` * `~photutils.aperture.EllipticalAnnulus` |rarr| `~regions.EllipseAnnulusPixelRegion` * `~photutils.aperture.SkyEllipticalAnnulus` |rarr| `~regions.EllipseAnnulusSkyRegion` * `~photutils.aperture.RectangularAnnulus` |rarr| `~regions.RectangleAnnulusPixelRegion` * `~photutils.aperture.SkyRectangularAnnulus` |rarr| `~regions.RectangleAnnulusSkyRegion` Examples -------- >>> from photutils.aperture import CircularAperture, aperture_to_region >>> aperture = CircularAperture((10, 20), r=5) >>> region = aperture_to_region(aperture) >>> region >>> aperture = CircularAperture(((10, 20), (30, 40)), r=5) >>> region = aperture_to_region(aperture) >>> region , ])> """ from regions import Regions if not isinstance(aperture, Aperture): raise TypeError('Input aperture must be an Aperture object') if aperture.shape == (): return _scalar_aperture_to_region(aperture) # multiple aperture positions return a Regions object regs = [_scalar_aperture_to_region(aper) for aper in aperture] return Regions(regs) def _scalar_aperture_to_region(aperture): """ Convert a given scalar `~photutils.aperture.Aperture` object to a `regions.Region` object. Parameters ---------- aperture : `~photutils.aperture.Aperture` An `~photutils.aperture.Aperture` object to convert. The ``aperture`` must have a single position (scalar). Returns ------- region : `regions.Region` or `regions.Regions` An equivalent `regions.Region` object. """ from regions import (CircleAnnulusPixelRegion, CircleAnnulusSkyRegion, CirclePixelRegion, CircleSkyRegion, EllipseAnnulusPixelRegion, EllipseAnnulusSkyRegion, EllipsePixelRegion, EllipseSkyRegion, PixCoord, RectangleAnnulusPixelRegion, RectangleAnnulusSkyRegion, RectanglePixelRegion, RectangleSkyRegion) if aperture.shape != (): msg = 'Only scalar (single-position) apertures are supported.' raise ValueError(msg) if isinstance(aperture, CircularAperture): region = CirclePixelRegion(PixCoord(*aperture.positions), aperture.r) elif isinstance(aperture, SkyCircularAperture): region = CircleSkyRegion(aperture.positions, aperture.r) elif isinstance(aperture, EllipticalAperture): region = EllipsePixelRegion( PixCoord(*aperture.positions), aperture.a * 2, aperture.b * 2, angle=aperture.theta) elif isinstance(aperture, SkyEllipticalAperture): region = EllipseSkyRegion( aperture.positions, aperture.a * 2, aperture.b * 2, angle=(aperture.theta + (90 * u.deg))) elif isinstance(aperture, RectangularAperture): region = RectanglePixelRegion( PixCoord(*aperture.positions), aperture.w, aperture.h, angle=aperture.theta) elif isinstance(aperture, SkyRectangularAperture): region = RectangleSkyRegion( aperture.positions, aperture.w, aperture.h, angle=(aperture.theta + (90 * u.deg))) elif isinstance(aperture, CircularAnnulus): region = CircleAnnulusPixelRegion( PixCoord(*aperture.positions), aperture.r_in, aperture.r_out) elif isinstance(aperture, SkyCircularAnnulus): region = CircleAnnulusSkyRegion( aperture.positions, aperture.r_in, aperture.r_out) elif isinstance(aperture, EllipticalAnnulus): region = EllipseAnnulusPixelRegion( PixCoord(*aperture.positions), aperture.a_in * 2, aperture.a_out * 2, aperture.b_in * 2, aperture.b_out * 2, angle=aperture.theta) elif isinstance(aperture, SkyEllipticalAnnulus): region = EllipseAnnulusSkyRegion( aperture.positions, aperture.a_in * 2, aperture.a_out * 2, aperture.b_in * 2, aperture.b_out * 2, angle=(aperture.theta + (90 * u.deg))) elif isinstance(aperture, RectangularAnnulus): region = RectangleAnnulusPixelRegion( PixCoord(*aperture.positions), aperture.w_in, aperture.w_out, aperture.h_in, aperture.h_out, angle=aperture.theta) elif isinstance(aperture, SkyRectangularAnnulus): region = RectangleAnnulusSkyRegion( aperture.positions, aperture.w_in, aperture.w_out, aperture.h_in, aperture.h_out, angle=(aperture.theta + (90 * u.deg))) else: # pragma: no cover raise TypeError('Cannot convert input aperture to a Region object') return region def _shapely_polygon_to_region(polygon): """ Convert a `shapely.geometry.polygon.Polygon` object to a `regions.PolygonPixelRegion` object. Parameters ---------- polygon : `shapely.geometry.polygon.Polygon` A `shapely.geometry.polygon.Polygon` object. Returns ------- region : `regions.PolygonPixelRegion` An equivalent `regions.PolygonPixelRegion` object. Raises ------ `TypeError` The given ``polygon`` is not a `shapely.geometry.polygon.Polygon` object. Notes ----- The `regions.PolygonPixelRegion` 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.geometry import Polygon >>> from photutils.aperture.converters import _shapely_polygon_to_region >>> polygon = Polygon([(1, 1), (3, 1), (2, 4), (1, 2)]) >>> region = _shapely_polygon_to_region(polygon) >>> region """ from regions import PixCoord, PolygonPixelRegion from shapely.geometry import Polygon if not isinstance(polygon, Polygon): raise TypeError('Input polygon must be a shapely Polygon object') x, y = np.transpose(polygon.exterior.coords[:-1]) return PolygonPixelRegion(vertices=PixCoord(x=x, y=y)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/core.py0000644000175100001660000007465214755160622020363 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines the base aperture classes. """ import abc import inspect import warnings from copy import deepcopy import astropy.units as u import numpy as np from astropy.coordinates import SkyCoord from astropy.utils import lazyproperty from photutils.aperture.bounding_box import BoundingBox from photutils.utils._wcs_helpers import _pixel_scale_angle_at_skycoord __all__ = ['Aperture', 'PixelAperture', 'SkyAperture'] class Aperture(metaclass=abc.ABCMeta): """ Abstract base class for all apertures. """ _params = () def __len__(self): if self.isscalar: raise TypeError(f'A scalar {self.__class__.__name__!r} object ' 'has no len()') return self.shape[0] def __getitem__(self, index): if self.isscalar: raise TypeError(f'A scalar {self.__class__.__name__!r} object ' 'cannot be indexed') kwargs = {} for param in self._params: if param == 'positions': # slice the positions array kwargs[param] = getattr(self, param)[index] else: kwargs[param] = getattr(self, param) return self.__class__(**kwargs) def __iter__(self): for i in range(len(self)): yield self.__getitem__(i) def _positions_str(self, prefix=None): if isinstance(self, PixelAperture): return np.array2string(self.positions, separator=', ', prefix=prefix) if isinstance(self, SkyAperture): return repr(self.positions) raise TypeError('Aperture must be a subclass of PixelAperture ' 'or SkyAperture') def __repr__(self): prefix = f'{self.__class__.__name__}' cls_info = [] for param in self._params: if param == 'positions': cls_info.append(self._positions_str(prefix)) else: cls_info.append(f'{param}={getattr(self, param)}') cls_info = ', '.join(cls_info) return f'<{prefix}({cls_info})>' def __str__(self): cls_info = [('Aperture', self.__class__.__name__)] for param in self._params: if param == 'positions': prefix = 'positions' cls_info.append((prefix, self._positions_str(prefix + ': '))) else: cls_info.append((param, getattr(self, param))) fmt = [f'{key}: {val}' for key, val in cls_info] return '\n'.join(fmt) def __eq__(self, other): """ Equality operator for `Aperture`. All Aperture properties are compared for strict equality except for Quantity parameters, which allow for different units if they are directly convertible. """ if not isinstance(other, self.__class__): return False self_params = list(self._params) other_params = list(other._params) # check that both have identical parameters if self_params != other_params: return False # now check the parameter values # Note that Quantity comparisons allow for different units if they # are directly convertible (e.g., 1.0 * u.deg == 60.0 * u.arcmin) try: for param in self_params: # np.any is used for SkyCoord array comparisons if np.any(getattr(self, param) != getattr(other, param)): return False except TypeError: # TypeError is raised from SkyCoord comparison when they do # not have equivalent frames. Here return False instead of # the TypeError. return False return True def __ne__(self, other): """ Inequality operator for `Aperture`. """ return not self == other @property def _lazyproperties(self): """ A list of all class lazyproperties (even in superclasses). """ def islazyproperty(obj): return isinstance(obj, lazyproperty) return [i[0] for i in inspect.getmembers(self.__class__, predicate=islazyproperty)] def copy(self): """ Make an 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`. """ raise NotImplementedError('Needs to be implemented in a subclass.') @lazyproperty def shape(self): """ The shape of the instance. """ if isinstance(self.positions, SkyCoord): return self.positions.shape return self.positions.shape[:-1] @lazyproperty def isscalar(self): """ Whether the instance is scalar (i.e., a single position). """ return self.shape == () class PixelAperture(Aperture): """ Abstract base class for apertures defined in pixel coordinates. """ @lazyproperty def _default_patch_properties(self): """ A dictionary of default matplotlib.patches.Patch properties. """ mpl_params = {} # matplotlib.patches.Patch default is ``fill=True`` mpl_params['fill'] = False return mpl_params @staticmethod def _translate_mask_mode(mode, subpixels, rectangle=False): if mode not in ('center', 'subpixel', 'exact'): raise ValueError(f'Invalid mask mode: {mode}') if rectangle and mode == 'exact': mode = 'subpixel' subpixels = 32 if ((mode == 'subpixel') and (not isinstance(subpixels, int) or subpixels <= 0)): raise ValueError('subpixels must be a strictly positive integer') if mode == 'center': use_exact = 0 subpixels = 1 elif mode == 'subpixel': use_exact = 0 elif mode == 'exact': use_exact = 1 subpixels = 1 return use_exact, subpixels @abc.abstractmethod def _xy_extents(self): """ The (x, y) extents of the aperture measured from the center position. In other words, the (x, y) extents are half of the aperture minimal bounding box size in each dimension. """ raise NotImplementedError('Needs to be implemented in a subclass.') @lazyproperty def _positions(self): """ The aperture positions, always as a 2D ndarray. """ return np.atleast_2d(self.positions) @lazyproperty def _bbox(self): """ The minimal bounding box for the aperture, always as a list of `~photutils.aperture.BoundingBox` instances. """ x_delta, y_delta = self._xy_extents xmin = self._positions[:, 0] - x_delta xmax = self._positions[:, 0] + x_delta ymin = self._positions[:, 1] - y_delta ymax = self._positions[:, 1] + y_delta return [BoundingBox.from_float(x0, x1, y0, y1) for x0, x1, y0, y1 in zip(xmin, xmax, ymin, ymax, strict=True)] @lazyproperty def bbox(self): """ The minimal bounding box for the aperture. If the aperture is scalar then a single `~photutils.aperture.BoundingBox` is returned, otherwise a list of `~photutils.aperture.BoundingBox` is returned. """ if self.isscalar: return self._bbox[0] return self._bbox @lazyproperty def _centered_edges(self): """ A list of ``(xmin, xmax, ymin, ymax)`` tuples, one for each position, of the pixel edges after recentering the aperture at the origin. These pixel edges are used by the low-level `photutils.geometry` functions. """ edges = [] for position, bbox in zip(self._positions, self._bbox, strict=True): xmin = bbox.ixmin - 0.5 - position[0] xmax = bbox.ixmax - 0.5 - position[0] ymin = bbox.iymin - 0.5 - position[1] ymax = bbox.iymax - 0.5 - position[1] edges.append((xmin, xmax, ymin, ymax)) return edges @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 """ raise NotImplementedError('Needs to be implemented in a subclass.') def area_overlap(self, data, *, mask=None, method='exact', subpixels=5): """ Return the area of overlap between the data and the aperture. This method takes into account the aperture mask method, masked data pixels (``mask`` keyword), and partial/no overlap of the aperture with the data. In other words, it returns the area that used to compute the aperture sum (assuming identical inputs). Use the `area` method to calculate the exact analytical area of the aperture shape. Parameters ---------- data : array_like or `~astropy.units.Quantity` A 2D array. mask : array_like (bool), optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from the area overlap. method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid. Not all options are available for all aperture types. Note that the more precise methods are generally slower. The following methods are available: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. Returns ------- areas : float or array_like The area (in pixels**2) of overlap between the data and the aperture. See Also -------- area """ apermasks = self.to_mask(method=method, subpixels=subpixels) if self.isscalar: apermasks = (apermasks,) if mask is not None: mask = np.asarray(mask) if mask.shape != data.shape: raise ValueError('mask and data must have the same shape') areas = [] for apermask in apermasks: slc_large, slc_small = apermask.get_overlap_slices(data.shape) # if the aperture does not overlap the data return np.nan if slc_large is None: area = np.nan else: aper_weights = apermask.data[slc_small] if mask is not None: aper_weights[mask[slc_large]] = 0.0 area = np.sum(aper_weights) areas.append(area) areas = np.array(areas) if self.isscalar: return areas[0] return areas @abc.abstractmethod def to_mask(self, method='exact', subpixels=5): """ Return a mask for the aperture. Parameters ---------- method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid. Not all options are available for all aperture types. Note that the more precise methods are generally slower. The following methods are available: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. Returns ------- mask : `~photutils.aperture.ApertureMask` or list of \ `~photutils.aperture.ApertureMask` A mask for the aperture. If the aperture is scalar then a single `~photutils.aperture.ApertureMask` is returned, otherwise a list of `~photutils.aperture.ApertureMask` is returned. """ raise NotImplementedError('Needs to be implemented in a subclass.') def do_photometry(self, data, error=None, mask=None, method='exact', subpixels=5): """ Perform aperture photometry on the input data. Parameters ---------- data : array_like or `~astropy.units.Quantity` instance The 2D array on which to perform photometry. ``data`` should be background subtracted. error : array_like or `~astropy.units.Quantity`, optional The pixel-wise Gaussian 1-sigma errors of the input ``data``. ``error`` is assumed to include *all* sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. mask : array_like (bool), optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid. Not all options are available for all aperture types. Note that the more precise methods are generally slower. The following methods are available: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. Returns ------- aperture_sums : `~numpy.ndarray` or `~astropy.units.Quantity` The sums within each aperture. aperture_sum_errs : `~numpy.ndarray` or `~astropy.units.Quantity` The errors on the sums within each aperture. Notes ----- `RectangularAperture` and `RectangularAnnulus` photometry with the "exact" method uses a subpixel approximation by subdividing each data pixel by a factor of 1024 (``subpixels = 32``). For rectangular aperture widths and heights in the range from 2 to 100 pixels, this subpixel approximation gives results typically within 0.001 percent or better of the exact value. The differences can be larger for smaller apertures (e.g., aperture sizes of one pixel or smaller). For such small sizes, it is recommend to set ``method='subpixel'`` with a larger ``subpixels`` size. """ data = np.asanyarray(data) if data.ndim != 2: raise ValueError('data must be a 2D array.') if error is not None: error = np.asanyarray(error) if error.shape != data.shape: raise ValueError('error and data must have the same shape.') # check Quantity inputs unit = {getattr(arr, 'unit', None) for arr in (data, error) if arr is not None} if len(unit) > 1: raise ValueError('If data or error has units, then they both must ' 'have the same units.') # strip data and error units for performance unit = unit.pop() if unit is not None: unit = data.unit data = data.value if error is not None: error = error.value apermasks = self.to_mask(method=method, subpixels=subpixels) if self.isscalar: apermasks = (apermasks,) aperture_sums = [] aperture_sum_errs = [] for apermask in apermasks: (slc_large, aper_weights, pixel_mask) = apermask._get_overlap_cutouts(data.shape, mask=mask) # no overlap of the aperture with the data if slc_large is None: aperture_sums.append(np.nan) aperture_sum_errs.append(np.nan) continue with warnings.catch_warnings(): # ignore multiplication with non-finite data values warnings.simplefilter('ignore', RuntimeWarning) values = (data[slc_large] * aper_weights)[pixel_mask] aperture_sums.append(values.sum()) if error is not None: variance = (error[slc_large]**2 * aper_weights)[pixel_mask] aperture_sum_errs.append(np.sqrt(variance.sum())) aperture_sums = np.array(aperture_sums) aperture_sum_errs = np.array(aperture_sum_errs) # apply units if unit is not None: aperture_sums <<= unit aperture_sum_errs <<= unit return aperture_sums, aperture_sum_errs @staticmethod def _make_annulus_path(patch_inner, patch_outer): """ Define a matplotlib annulus path from two patches. This preserves the cubic Bezier curves (CURVE4) of the aperture paths. """ import matplotlib.path as mpath path_inner = patch_inner.get_path() transform_inner = patch_inner.get_transform() path_inner = transform_inner.transform_path(path_inner) path_outer = patch_outer.get_path() transform_outer = patch_outer.get_transform() path_outer = transform_outer.transform_path(path_outer) verts_inner = path_inner.vertices[:-1][::-1] verts_inner = np.concatenate((verts_inner, [verts_inner[-1]])) verts = np.vstack((path_outer.vertices, verts_inner)) codes = np.hstack((path_outer.codes, path_inner.codes)) return mpath.Path(verts, codes) def _define_patch_params(self, origin=(0, 0), **kwargs): """ Define the aperture patch position and set any default matplotlib patch keywords (e.g., ``fill=False``). Parameters ---------- origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : dict, optional Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- xy_positions : `~numpy.ndarray` The aperture patch positions. patch_params : dict Any keyword arguments accepted by `matplotlib.patches.Patch`. """ xy_positions = deepcopy(self._positions) xy_positions[:, 0] -= origin[0] xy_positions[:, 1] -= origin[1] patch_params = self._default_patch_properties.copy() patch_params.update(kwargs) return xy_positions, patch_params @abc.abstractmethod def _to_patch(self, origin=(0, 0), **kwargs): """ Return a `~matplotlib.patches.Patch` for the aperture. Parameters ---------- origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : dict, optional Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- patch : `~matplotlib.patches.Patch` or list of \ `~matplotlib.patches.Patch` A patch for the aperture. If the aperture is scalar then a single `~matplotlib.patches.Patch` is returned, otherwise a list of `~matplotlib.patches.Patch` is returned. """ raise NotImplementedError('Needs to be implemented in a subclass.') def plot(self, ax=None, origin=(0, 0), **kwargs): """ Plot the aperture on a matplotlib `~matplotlib.axes.Axes` instance. Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : dict, 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 def _to_sky_params(self, wcs): """ Convert the pixel aperture parameters to those for a sky aperture. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- sky_params : dict A dictionary of parameters for an equivalent sky aperture. """ sky_params = {} xpos, ypos = np.transpose(self.positions) sky_params['positions'] = skypos = wcs.pixel_to_world(xpos, ypos) # Aperture objects require scalar shape parameters (e.g., # radius, a, b, theta, etc.), therefore we must calculate the # pixel scale and angle at only a single sky position, which # we take as the first aperture position. For apertures with # multiple positions used with a WCS that contains distortions # (e.g., a spatially-dependent pixel scale), this may lead to # unexpected results (e.g., results that are dependent of the # order of the positions). There is no good way to fix this with # the current Aperture API allowing multiple positions. if not self.isscalar: skypos = skypos[0] _, pixscale, angle = _pixel_scale_angle_at_skycoord(skypos, wcs) for param in self._params: value = getattr(self, param) if param == 'positions': continue if param == 'theta': # photutils aperture sky angles are defined as the PA of # the semimajor axis (i.e., relative to the WCS latitude # axis). region sky angles are defined relative to the WCS # longitude axis. value = value - angle.to(u.rad) else: value = (value * u.pix * pixscale).to(u.arcsec) sky_params[param] = value return sky_params @abc.abstractmethod def to_sky(self, wcs): """ Convert the aperture to a `SkyAperture` object defined in celestial coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `SkyAperture` object A `SkyAperture` object. """ raise NotImplementedError('Needs to be implemented in a subclass.') class SkyAperture(Aperture): """ Abstract base class for all apertures defined in celestial coordinates. """ def _to_pixel_params(self, wcs): """ Convert the sky aperture parameters to those for a pixel aperture. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- pixel_params : dict A dictionary of parameters for an equivalent pixel aperture. """ pixel_params = {} xpos, ypos = wcs.world_to_pixel(self.positions) pixel_params['positions'] = np.transpose((xpos, ypos)) # Aperture objects require scalar shape parameters (e.g., # radius, a, b, theta, etc.), therefore we must calculate the # pixel scale and angle at only a single sky position, which # we take as the first aperture position. For apertures with # multiple positions used with a WCS that contains distortions # (e.g., a spatially-dependent pixel scale), this may lead to # unexpected results (e.g., results that are dependent of the # order of the positions). There is no good way to fix this with # the current Aperture API allowing multiple positions. skypos = self.positions if self.isscalar else self.positions[0] _, pixscale, angle = _pixel_scale_angle_at_skycoord(skypos, wcs) for param in self._params: value = getattr(self, param) if param == 'positions': continue if param == 'theta': # photutils aperture sky angles are defined as the PA of # the semimajor axis (i.e., relative to the WCS latitude # axis). region sky angles are defined relative to the WCS # longitude axis. value = (value + angle).to(u.radian) elif value.unit.physical_type == 'angle': value = (value / pixscale).to(u.pixel).value else: value = value.value pixel_params[param] = value return pixel_params @abc.abstractmethod def to_pixel(self, wcs): """ Convert the aperture to a `PixelAperture` object defined in pixel coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `PixelAperture` object A `PixelAperture` object. """ raise NotImplementedError('Needs to be implemented in a subclass.') def _aperture_metadata(aperture, index=''): """ Return a dictionary of aperture metadata. Parameters ---------- aperture : `Aperture` An aperture object. index : str, optional A string that will be prepended to each metadata key. Returns ------- meta : dict A dictionary of aperture metadata """ params = aperture._params meta = {} for param in params: if param != 'positions': meta[f'aperture{index}'] = aperture.__class__.__name__ meta[f'aperture{index}_{param}'] = getattr(aperture, param) return meta ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/ellipse.py0000644000175100001660000005174114755160622021062 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines elliptical and elliptical-annulus apertures in both pixel and sky coordinates. """ import math import astropy.units as u import numpy as np from photutils.aperture.attributes import (PixelPositions, PositiveScalar, PositiveScalarAngle, ScalarAngle, ScalarAngleOrValue, SkyCoordPositions) from photutils.aperture.core import PixelAperture, SkyAperture from photutils.aperture.mask import ApertureMask from photutils.geometry import elliptical_overlap_grid __all__ = [ 'EllipticalAnnulus', 'EllipticalAperture', 'EllipticalMaskMixin', 'SkyEllipticalAnnulus', 'SkyEllipticalAperture', ] class EllipticalMaskMixin: """ Mixin class to create masks for elliptical and elliptical-annulus aperture objects. """ def to_mask(self, method='exact', subpixels=5): """ Return a mask for the aperture. Parameters ---------- method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid. Not all options are available for all aperture types. Note that the more precise methods are generally slower. The following methods are available: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. Returns ------- mask : `~photutils.aperture.ApertureMask` or list of \ `~photutils.aperture.ApertureMask` A mask for the aperture. If the aperture is scalar then a single `~photutils.aperture.ApertureMask` is returned, otherwise a list of `~photutils.aperture.ApertureMask` is returned. """ use_exact, subpixels = self._translate_mask_mode(method, subpixels) if hasattr(self, 'a'): a = self.a b = self.b elif hasattr(self, 'a_in'): # annulus a = self.a_out b = self.b_out else: raise ValueError('Cannot determine the aperture shape.') masks = [] for bbox, edges in zip(self._bbox, self._centered_edges, strict=True): ny, nx = bbox.shape theta_rad = self.theta.to(u.radian).value mask = elliptical_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, a, b, theta_rad, use_exact, subpixels) # subtract the inner ellipse for an annulus if hasattr(self, 'a_in'): mask -= elliptical_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.a_in, self.b_in, theta_rad, use_exact, subpixels) masks.append(ApertureMask(mask, bbox)) if self.isscalar: return masks[0] return masks @staticmethod def _calc_extents(semimajor_axis, semiminor_axis, theta): """ Calculate half of the bounding box extents of an ellipse. """ theta_rad = theta.to(u.radian).value cos_theta = np.cos(theta_rad) sin_theta = np.sin(theta_rad) semimajor_x = semimajor_axis * cos_theta semimajor_y = semimajor_axis * sin_theta semiminor_x = semiminor_axis * -sin_theta semiminor_y = semiminor_axis * cos_theta x_extent = np.sqrt(semimajor_x**2 + semiminor_x**2) y_extent = np.sqrt(semimajor_y**2 + semiminor_y**2) return x_extent, y_extent class EllipticalAperture(EllipticalMaskMixin, PixelAperture): """ An elliptical aperture defined in pixel coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : array_like The pixel coordinates of the aperture center(s) in one of the following formats: * single ``(x, y)`` pair as a tuple, list, or `~numpy.ndarray` * tuple, list, or `~numpy.ndarray` of ``(x, y)`` pairs a : float The semimajor axis of the ellipse in pixels. b : float The semiminor axis of the ellipse in pixels. theta : float or `~astropy.units.Quantity`, optional The rotation angle as an angular quantity (`~astropy.units.Quantity` or `~astropy.coordinates.Angle`) or value in radians (as a float) from the positive ``x`` axis. The rotation angle increases counterclockwise. Raises ------ ValueError : `ValueError` If either axis (``a`` or ``b``) is negative. Examples -------- >>> from astropy.coordinates import Angle >>> from photutils.aperture import EllipticalAperture >>> theta = Angle(80, 'deg') >>> aper = EllipticalAperture([10.0, 20.0], 5.0, 3.0) >>> aper = EllipticalAperture((10.0, 20.0), 5.0, 3.0, theta=theta) >>> pos1 = (10.0, 20.0) # (x, y) >>> pos2 = (30.0, 40.0) >>> pos3 = (50.0, 60.0) >>> aper = EllipticalAperture([pos1, pos2, pos3], 5.0, 3.0) >>> aper = EllipticalAperture((pos1, pos2, pos3), 5.0, 3.0, theta=theta) """ _params = ('positions', 'a', 'b', 'theta') positions = PixelPositions('The center pixel position(s).') a = PositiveScalar('The semimajor axis in pixels.') b = PositiveScalar('The semiminor axis in pixels.') theta = ScalarAngleOrValue('The counterclockwise rotation angle as an ' 'angular Quantity or value in radians from ' 'the positive x axis.') def __init__(self, positions, a, b, theta=0.0): self.positions = positions self.a = a self.b = b self.theta = theta @property def _xy_extents(self): return self._calc_extents(self.a, self.b, self.theta) @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(u.deg).value patches = [mpatches.Ellipse(xy_position, 2.0 * self.a, 2.0 * self.b, angle=angle, **patch_kwargs) for xy_position in xy_positions] if self.isscalar: return patches[0] return patches def to_mask(self, method='exact', subpixels=5): return EllipticalMaskMixin.to_mask(self, method=method, subpixels=subpixels) def to_sky(self, wcs): """ Convert the aperture to a `SkyEllipticalAperture` object defined in celestial coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `SkyEllipticalAperture` object A `SkyEllipticalAperture` object. """ return SkyEllipticalAperture(**self._to_sky_params(wcs)) class EllipticalAnnulus(EllipticalMaskMixin, PixelAperture): r""" An elliptical annulus aperture defined in pixel coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : array_like The pixel coordinates of the aperture center(s) in one of the following formats: * single ``(x, y)`` pair as a tuple, list, or `~numpy.ndarray` * tuple, list, or `~numpy.ndarray` of ``(x, y)`` pairs a_in : float The inner semimajor axis of the elliptical annulus in pixels. a_out : float The outer semimajor axis of the elliptical annulus in pixels. b_out : float The outer semiminor axis of the elliptical annulus in pixels. b_in : `None` or float, optional The inner semiminor axis of the elliptical annulus in pixels. If `None`, then the inner semiminor axis is calculated as: .. math:: b_{in} = b_{out} \left(\frac{a_{in}}{a_{out}}\right) theta : float or `~astropy.units.Quantity`, optional The rotation angle as an angular quantity (`~astropy.units.Quantity` or `~astropy.coordinates.Angle`) or value in radians (as a float) from the positive ``x`` axis. The rotation angle increases counterclockwise. Raises ------ ValueError : `ValueError` If inner semimajor axis (``a_in``) is greater than outer semimajor axis (``a_out``). ValueError : `ValueError` If either the inner semimajor axis (``a_in``) or the outer semiminor axis (``b_out``) is negative. Examples -------- >>> from astropy.coordinates import Angle >>> from photutils.aperture import EllipticalAnnulus >>> theta = Angle(80, 'deg') >>> aper = EllipticalAnnulus([10.0, 20.0], 3.0, 8.0, 5.0) >>> aper = EllipticalAnnulus((10.0, 20.0), 3.0, 8.0, 5.0, theta=theta) >>> pos1 = (10.0, 20.0) # (x, y) >>> pos2 = (30.0, 40.0) >>> pos3 = (50.0, 60.0) >>> aper = EllipticalAnnulus([pos1, pos2, pos3], 3.0, 8.0, 5.0) >>> aper = EllipticalAnnulus((pos1, pos2, pos3), 3.0, 8.0, 5.0, ... theta=theta) """ _params = ('positions', 'a_in', 'a_out', 'b_in', 'b_out', 'theta') positions = PixelPositions('The center pixel position(s).') a_in = PositiveScalar('The inner semimajor axis in pixels.') a_out = PositiveScalar('The outer semimajor axis in pixels.') b_in = PositiveScalar('The inner semiminor axis in pixels.') b_out = PositiveScalar('The outer semiminor axis in pixels.') theta = ScalarAngleOrValue('The counterclockwise rotation angle as an ' 'angular Quantity or value in radians from ' 'the positive x axis.') def __init__(self, positions, a_in, a_out, b_out, b_in=None, theta=0.0): if not a_out > a_in: raise ValueError('"a_out" must be greater than "a_in".') self.positions = positions self.a_in = a_in self.a_out = a_out self.b_out = b_out if b_in is None: b_in = self.b_out * self.a_in / self.a_out elif not b_out > b_in: raise ValueError('"b_out" must be greater than "b_in".') self.b_in = b_in self.theta = theta @property def _xy_extents(self): return self._calc_extents(self.a_out, self.b_out, self.theta) @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(u.deg).value for xy_position in xy_positions: patch_inner = mpatches.Ellipse(xy_position, 2.0 * self.a_in, 2.0 * self.b_in, angle=angle) patch_outer = mpatches.Ellipse(xy_position, 2.0 * self.a_out, 2.0 * self.b_out, angle=angle) path = self._make_annulus_path(patch_inner, patch_outer) patches.append(mpatches.PathPatch(path, **patch_kwargs)) if self.isscalar: return patches[0] return patches def to_mask(self, method='exact', subpixels=5): return EllipticalMaskMixin.to_mask(self, method=method, subpixels=subpixels) def to_sky(self, wcs): """ Convert the aperture to a `SkyEllipticalAnnulus` object defined in celestial coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `SkyEllipticalAnnulus` object A `SkyEllipticalAnnulus` object. """ return SkyEllipticalAnnulus(**self._to_sky_params(wcs)) class SkyEllipticalAperture(SkyAperture): """ An elliptical aperture defined in sky coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The celestial coordinates of the aperture center(s). This can be either scalar coordinates or an array of coordinates. a : scalar `~astropy.units.Quantity` The semimajor axis of the ellipse in angular units. b : scalar `~astropy.units.Quantity` The semiminor axis of the ellipse in angular units. theta : scalar `~astropy.units.Quantity`, optional The position angle (in angular units) of the ellipse semimajor axis. For a right-handed world coordinate system, the position angle increases counterclockwise from North (PA=0). Examples -------- >>> from astropy.coordinates import SkyCoord >>> import astropy.units as u >>> from photutils.aperture import SkyEllipticalAperture >>> positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') >>> aper = SkyEllipticalAperture(positions, 1.0*u.arcsec, 0.5*u.arcsec) """ _params = ('positions', 'a', 'b', 'theta') positions = SkyCoordPositions('The center position(s) in sky coordinates.') a = PositiveScalarAngle('The semimajor axis in angular units.') b = PositiveScalarAngle('The semiminor axis in angular units.') theta = ScalarAngle('The position angle in angular units of the ellipse ' 'semimajor axis.') def __init__(self, positions, a, b, theta=0.0 * u.deg): self.positions = positions self.a = a self.b = b self.theta = theta def to_pixel(self, wcs): """ Convert the aperture to an `EllipticalAperture` object defined in pixel coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `EllipticalAperture` object An `EllipticalAperture` object. """ return EllipticalAperture(**self._to_pixel_params(wcs)) class SkyEllipticalAnnulus(SkyAperture): r""" An elliptical annulus aperture defined in sky coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The celestial coordinates of the aperture center(s). This can be either scalar coordinates or an array of coordinates. a_in : scalar `~astropy.units.Quantity` The inner semimajor axis in angular units. a_out : scalar `~astropy.units.Quantity` The outer semimajor axis in angular units. b_out : scalar `~astropy.units.Quantity` The outer semiminor axis in angular units. b_in : `None` or scalar `~astropy.units.Quantity` The inner semiminor axis in angular units. If `None`, then the inner semiminor axis is calculated as: .. math:: b_{in} = b_{out} \left(\frac{a_{in}}{a_{out}}\right) theta : scalar `~astropy.units.Quantity`, optional The position angle (in angular units) of the ellipse semimajor axis. For a right-handed world coordinate system, the position angle increases counterclockwise from North (PA=0). Examples -------- >>> from astropy.coordinates import SkyCoord >>> import astropy.units as u >>> from photutils.aperture import SkyEllipticalAnnulus >>> positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') >>> aper = SkyEllipticalAnnulus(positions, 0.5*u.arcsec, 2.0*u.arcsec, ... 1.0*u.arcsec) """ _params = ('positions', 'a_in', 'a_out', 'b_in', 'b_out', 'theta') positions = SkyCoordPositions('The center position(s) in sky coordinates.') a_in = PositiveScalarAngle('The inner semimajor axis in angular units.') a_out = PositiveScalarAngle('The outer semimajor axis in angular units.') b_in = PositiveScalarAngle('The inner semiminor axis in angular units.') b_out = PositiveScalarAngle('The outer semiminor axis in angular units.') theta = ScalarAngle('The position angle in angular units of the ellipse ' 'semimajor axis.') def __init__(self, positions, a_in, a_out, b_out, b_in=None, theta=0.0 * u.deg): if not a_out > a_in: raise ValueError('"a_out" must be greater than "a_in".') self.positions = positions self.a_in = a_in self.a_out = a_out self.b_out = b_out if b_in is None: b_in = self.b_out * self.a_in / self.a_out elif not b_out > b_in: raise ValueError('"b_out" must be greater than "b_in".') self.b_in = b_in self.theta = theta def to_pixel(self, wcs): """ Convert the aperture to an `EllipticalAnnulus` object defined in pixel coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `EllipticalAnnulus` object An `EllipticalAnnulus` object. """ return EllipticalAnnulus(**self._to_pixel_params(wcs)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/mask.py0000644000175100001660000002607514755160622020362 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines a class for aperture masks. """ import warnings import astropy.units as u import numpy as np from astropy.utils import minversion __all__ = ['ApertureMask'] COPY_IF_NEEDED = False if not minversion(np, '2.0') else None class ApertureMask: """ Class for an aperture mask. Parameters ---------- data : array_like A 2D array representing the fractional overlap of an aperture on the pixel grid. This should be the full-sized (i.e., not truncated) array that is the direct output of one of the low-level `photutils.geometry` functions. bbox : `photutils.aperture.BoundingBox` The bounding box object defining the aperture minimal bounding box. """ def __init__(self, data, bbox): self.data = np.asanyarray(data) if self.data.shape != bbox.shape: raise ValueError('mask data and bounding box must have the same ' 'shape') self.bbox = bbox self._mask = (self.data == 0) def __array__(self, dtype=None, copy=COPY_IF_NEEDED): """ Array representation of the mask data array (e.g., for matplotlib). """ return np.array(self.data, dtype=dtype, copy=copy) @property def shape(self): """ The shape of the mask data array. """ return self.data.shape def get_overlap_slices(self, shape): """ Get slices for the overlapping part of the aperture mask and a 2D array. Parameters ---------- shape : 2-tuple of int The shape of the 2D array. Returns ------- slices_large : tuple of slices or `None` A tuple of slice objects for each axis of the large array, such that ``large_array[slices_large]`` extracts the region of the large array that overlaps with the small array. `None` is returned if there is no overlap of the bounding box with the given image shape. slices_small : tuple of slices or `None` A tuple of slice objects for each axis of the aperture mask array such that ``small_array[slices_small]`` extracts the region that is inside the large array. `None` is returned if there is no overlap of the bounding box with the given image shape. """ return self.bbox.get_overlap_slices(shape) def to_image(self, shape, dtype=float): """ Return an image of the mask in a 2D array of the given shape, taking any edge effects into account. Parameters ---------- shape : tuple of int The ``(ny, nx)`` shape of the output array. dtype : data-type, optional The desired data type for the array. This should be a floating data type if the `ApertureMask` was created with the "exact" or "subpixel" mode, otherwise the fractional mask weights will be altered. A integer data type may be used if the `ApertureMask` was created with the "center" mode. Returns ------- result : `~numpy.ndarray` A 2D array of the mask. """ if len(shape) != 2: raise ValueError('input shape must have 2 elements.') # find the overlap of the mask on the output image shape slices_large, slices_small = self.get_overlap_slices(shape) if slices_small is None: return None # no overlap # insert the mask into the output image image = np.zeros(shape, dtype=dtype) image[slices_large] = self.data[slices_small] return image def cutout(self, data, fill_value=0.0, copy=False): """ Create a cutout from the input data over the mask bounding box, taking any edge effects into account. Parameters ---------- data : array_like A 2D array on which to apply the aperture mask. fill_value : float, optional The value used to fill pixels where the aperture mask does not overlap with the input ``data``. The default is 0. copy : bool, optional If `True` then the returned cutout array will always be hold a copy of the input ``data``. If `False` and the mask is fully within the input ``data``, then the returned cutout array will be a view into the input ``data``. In cases where the mask partially overlaps or has no overlap with the input ``data``, the returned cutout array will always hold a copy of the input ``data`` (i.e., this keyword has no effect). Returns ------- result : `~numpy.ndarray` or `None` A 2D array cut out from the input ``data`` representing the same cutout region as the aperture mask. If there is a partial overlap of the aperture mask with the input data, pixels outside of the data will be assigned to ``fill_value``. `None` is returned if there is no overlap of the aperture with the input ``data``. """ data = np.asanyarray(data) if data.ndim != 2: raise ValueError('data must be a 2D array.') # find the overlap of the mask on the output image shape slices_large, slices_small = self.get_overlap_slices(data.shape) if slices_small is None: return None # no overlap cutout_shape = (slices_small[0].stop - slices_small[0].start, slices_small[1].stop - slices_small[1].start) if cutout_shape == self.shape: cutout = data[slices_large] if copy: cutout = np.copy(cutout) return cutout # cutout is always a copy for partial overlap dtype = float if ~np.isfinite(fill_value) else data.dtype cutout = np.zeros(self.shape, dtype=dtype) cutout[:] = fill_value cutout[slices_small] = data[slices_large] if isinstance(data, u.Quantity): cutout <<= data.unit return cutout def multiply(self, data, fill_value=0.0): """ Multiply the aperture mask with the input data, taking any edge effects into account. The result is a mask-weighted cutout from the data. Parameters ---------- data : array_like or `~astropy.units.Quantity` The 2D array to multiply with the aperture mask. fill_value : float, optional The value is used to fill pixels where the aperture mask does not overlap with the input ``data``. The default is 0. Returns ------- result : `~numpy.ndarray` or `None` A 2D mask-weighted cutout from the input ``data``. If there is a partial overlap of the aperture mask with the input data, pixels outside of the data will be assigned to ``fill_value`` before being multiplied with the mask. `None` is returned if there is no overlap of the aperture with the input ``data``. """ cutout = self.cutout(data, fill_value=fill_value) if cutout is None: return None # ignore multiplication with non-finite data values with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) weighted_cutout = cutout * self.data # fill values outside of the mask but within the bounding box weighted_cutout[self._mask] = fill_value return weighted_cutout def _get_overlap_cutouts(self, shape, mask=None): """ Get the aperture mask weights, pixel mask, and slice for the overlap with the input shape. If input, the ``mask`` is included in the output pixel mask cutout. Parameters ---------- shape : tuple of int The shape of data. mask : array_like (bool), optional A boolean mask with the same shape as ``shape`` where a `True` value indicates a masked pixel. Returns ------- slices_large : tuple of slices or `None` A tuple of slice objects for each axis of the large array of given ``shape``, such that ``large_array[slices_large]`` extracts the region of the large array that overlaps with the small array. `None` is returned if there is no overlap of the bounding box with the given image shape. aper_weights: 2D float `~numpy.ndarray` The cutout aperture mask weights for the overlap. pixel_mask: 2D bool `~numpy.ndarray` The cutout pixel mask for the overlap. Notes ----- This method is separate from ``get_values`` to facilitate applying the same slices, aper_weights, and pixel_mask to multiple associated arrays (e.g., data and error arrays). It is used in this way by the `PixelAperture.do_photometry` method. """ if mask is not None and mask.shape != shape: raise ValueError('mask and data must have the same shape') slc_large, slc_small = self.get_overlap_slices(shape) if slc_large is None: # no overlap return None, None, None aper_weights = self.data[slc_small] pixel_mask = (aper_weights > 0) # good pixels if mask is not None: pixel_mask &= ~mask[slc_large] return slc_large, aper_weights, pixel_mask def get_values(self, data, mask=None): """ Get the mask-weighted pixel values from the data as a 1D array. If the ``ApertureMask`` was created with ``method='center'``, (where the mask weights are only 1 or 0), then the returned values will simply be pixel values extracted from the data. Parameters ---------- data : array_like or `~astropy.units.Quantity` The 2D array from which to get mask-weighted values. mask : array_like (bool), optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is not returned in the result. Returns ------- result : `~numpy.ndarray` A 1D array of mask-weighted pixel values from the input ``data``. If there is no overlap of the aperture with the input ``data``, the result will be an empty array with shape (0,). """ slc_large, aper_weights, pixel_mask = self._get_overlap_cutouts( data.shape, mask=mask) if slc_large is None: return np.array([]) # ignore multiplication with non-finite data values with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) # pixel_mask is used so that pixels value where data = 0 and # aper_weights != 0 are still returned return (data[slc_large] * aper_weights)[pixel_mask] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/photometry.py0000644000175100001660000002472214755160622021636 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines tools to perform aperture photometry. """ import warnings import astropy.units as u import numpy as np from astropy.nddata import NDData, StdDevUncertainty from astropy.table import QTable from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture.converters import region_to_aperture from photutils.aperture.core import Aperture, SkyAperture, _aperture_metadata from photutils.utils._misc import _get_meta __all__ = ['aperture_photometry'] def aperture_photometry(data, apertures, error=None, mask=None, method='exact', subpixels=5, wcs=None): """ Perform aperture photometry on the input data by summing the flux within the given aperture(s). Note that this function returns the sum of the (weighted) input ``data`` values within the aperture. It does not convert data in surface brightness units to flux or counts. Conversion from surface-brightness units should be performed before using this function. Parameters ---------- data : array_like, `~astropy.units.Quantity`, `~astropy.nddata.NDData` The 2D array on which to perform photometry. ``data`` should be background-subtracted. If ``data`` is a `~astropy.units.Quantity` array, then ``error`` (if input) must also be a `~astropy.units.Quantity` array with the same units. See the Notes section below for more information about `~astropy.nddata.NDData` input. apertures : `~photutils.aperture.Aperture`, 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 then they all must have the same position(s). If ``apertures`` contains a `~photutils.aperture.SkyAperture` or `~regions.SkyRegion` object, then a WCS must be input using the ``wcs`` keyword. Region objects are converted to aperture objects. error : array_like or `~astropy.units.Quantity`, optional The pixel-wise Gaussian 1-sigma errors of the input ``data``. ``error`` is assumed to include *all* sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. If a `~astropy.units.Quantity` array, then ``data`` must also be a `~astropy.units.Quantity` array with the same units. mask : array_like (bool), optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid. Not all options are available for all aperture types. Note that the more precise methods are generally slower. The following methods are available: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. wcs : WCS object, optional A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). If provided, the output table will include a ``'sky_center'`` column with the sky coordinates of the input aperture center(s). This keyword is required if the input ``apertures`` contains a `SkyAperture` or `~regions.SkyRegion`. Returns ------- table : `~astropy.table.QTable` A table of the photometry with the following columns: * ``'id'``: The source ID. * ``'xcenter'``, ``'ycenter'``: The ``x`` and ``y`` pixel coordinates of the input aperture center(s). * ``'sky_center'``: The sky coordinates of the input aperture center(s). Returned if a ``wcs`` is input. * ``'aperture_sum'``: The sum of the values within the aperture. * ``'aperture_sum_err'``: The corresponding uncertainty in the ``'aperture_sum'`` values. Returned only if the input ``error`` is not `None`. The table metadata includes the Astropy and Photutils version numbers and the `aperture_photometry` calling arguments. Notes ----- `~regions.Region` objects are converted to `Aperture` objects using the :func:`region_to_aperture` function. `RectangularAperture` and `RectangularAnnulus` photometry with the "exact" method uses a subpixel approximation by subdividing each data pixel by a factor of 1024 (``subpixels = 32``). For rectangular aperture widths and heights in the range from 2 to 100 pixels, this subpixel approximation gives results typically within 0.001 percent or better of the exact value. The differences can be larger for smaller apertures (e.g., aperture sizes of one pixel or smaller). For such small sizes, it is recommend to set ``method='subpixel'`` with a larger ``subpixels`` size. If the input ``data`` is a `~astropy.nddata.NDData` instance, then the ``error``, ``mask``, and ``wcs`` keyword inputs are ignored. Instead, these values should be defined as attributes in the `~astropy.nddata.NDData` object. In the case of ``error``, it must be defined in the ``uncertainty`` attribute with a `~astropy.nddata.StdDevUncertainty` instance. """ if isinstance(data, NDData): nddata_attr = {'error': error, 'mask': mask, 'wcs': wcs} for key, value in nddata_attr.items(): if value is not None: warnings.warn(f'The {key!r} keyword is be ignored. Its value ' 'is obtained from the input NDData object.', AstropyUserWarning) mask = data.mask wcs = data.wcs if isinstance(data.uncertainty, StdDevUncertainty): if data.uncertainty.unit is None: error = data.uncertainty.array else: error = data.uncertainty.array * data.uncertainty.unit if data.unit is not None: data = u.Quantity(data.data, unit=data.unit) else: data = data.data return aperture_photometry(data, apertures, error=error, mask=mask, method=method, subpixels=subpixels, wcs=wcs) single_aperture = False if 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, 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: raise ValueError('A WCS transform must be defined by the input ' 'data or the wcs keyword when using a ' 'SkyAperture object.') # used to include SkyCoord position in the output table skyaper = True skycoord_pos = apertures[0].positions apertures = [aper.to_pixel(wcs) for aper in apertures] # compare positions in pixels to avoid comparing SkyCoord objects positions = apertures[0].positions for aper in apertures[1:]: if not np.array_equal(aper.positions, positions): raise ValueError('Input apertures must all have identical ' 'positions.') # define output table meta data meta = _get_meta() calling_args = f"method='{method}', subpixels={subpixels}" meta['aperture_photometry_args'] = calling_args meta.update(aper_meta) tbl = QTable() tbl.meta.update(meta) # keep tbl.meta type positions = np.atleast_2d(apertures[0].positions) tbl['id'] = np.arange(positions.shape[0], dtype=int) + 1 xypos_pixel = np.transpose(positions) tbl['xcenter'] = xypos_pixel[0] tbl['ycenter'] = xypos_pixel[1] if skyaper: if skycoord_pos.isscalar: # create length-1 SkyCoord array tbl['sky_center'] = skycoord_pos.reshape((-1,)) else: tbl['sky_center'] = skycoord_pos if wcs is not None and not skyaper: tbl['sky_center'] = wcs.pixel_to_world(*np.transpose(positions)) sum_key_main = 'aperture_sum' sum_err_key_main = 'aperture_sum_err' for i, aper in enumerate(apertures): aper_sum, aper_sum_err = aper.do_photometry(data, error=error, mask=mask, method=method, subpixels=subpixels) sum_key = sum_key_main sum_err_key = sum_err_key_main if not single_aperture: sum_key += f'_{i}' sum_err_key += f'_{i}' tbl[sum_key] = aper_sum if error is not None: tbl[sum_err_key] = aper_sum_err return tbl ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/rectangle.py0000644000175100001660000005626414755160622021376 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines rectangular and rectangular-annulus apertures in both pixel and sky coordinates. """ import math import astropy.units as u import numpy as np from photutils.aperture.attributes import (PixelPositions, PositiveScalar, PositiveScalarAngle, ScalarAngle, ScalarAngleOrValue, SkyCoordPositions) from photutils.aperture.core import PixelAperture, SkyAperture from photutils.aperture.mask import ApertureMask from photutils.geometry import rectangular_overlap_grid __all__ = [ 'RectangularAnnulus', 'RectangularAperture', 'RectangularMaskMixin', 'SkyRectangularAnnulus', 'SkyRectangularAperture', ] class RectangularMaskMixin: """ Mixin class to create masks for rectangular or rectangular-annulus aperture objects. """ def to_mask(self, method='exact', subpixels=5): """ Return a mask for the aperture. Parameters ---------- method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid. Not all options are available for all aperture types. Note that the more precise methods are generally slower. The following methods are available: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. Returns ------- mask : `~photutils.aperture.ApertureMask` or list of \ `~photutils.aperture.ApertureMask` A mask for the aperture. If the aperture is scalar then a single `~photutils.aperture.ApertureMask` is returned, otherwise a list of `~photutils.aperture.ApertureMask` is returned. """ _, subpixels = self._translate_mask_mode(method, subpixels, rectangle=True) if hasattr(self, 'w'): w = self.w h = self.h elif hasattr(self, 'w_out'): # annulus w = self.w_out h = self.h_out else: raise ValueError('Cannot determine the aperture radius.') masks = [] for bbox, edges in zip(self._bbox, self._centered_edges, strict=True): ny, nx = bbox.shape theta_rad = self.theta.to(u.radian).value mask = rectangular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, w, h, theta_rad, 0, subpixels) # subtract the inner circle for an annulus if hasattr(self, 'w_in'): mask -= rectangular_overlap_grid(edges[0], edges[1], edges[2], edges[3], nx, ny, self.w_in, self.h_in, theta_rad, 0, subpixels) masks.append(ApertureMask(mask, bbox)) if self.isscalar: return masks[0] return masks @staticmethod def _calc_extents(width, height, theta): """ Calculate half of the bounding box extents of an ellipse. """ theta_rad = theta.to(u.radian).value half_width = width / 2.0 half_height = height / 2.0 sin_theta = math.sin(theta_rad) cos_theta = math.cos(theta_rad) x_extent1 = abs((half_width * cos_theta) - (half_height * sin_theta)) x_extent2 = abs((half_width * cos_theta) + (half_height * sin_theta)) y_extent1 = abs((half_width * sin_theta) + (half_height * cos_theta)) y_extent2 = abs((half_width * sin_theta) - (half_height * cos_theta)) x_extent = max(x_extent1, x_extent2) y_extent = max(y_extent1, y_extent2) return x_extent, y_extent @staticmethod def _lower_left_positions(positions, width, height, theta): """ Calculate lower-left positions from the input center positions. Used for creating `~matplotlib.patches.Rectangle` patch for the aperture. """ theta_rad = theta.to(u.radian).value half_width = width / 2.0 half_height = height / 2.0 sin_theta = math.sin(theta_rad) cos_theta = math.cos(theta_rad) xshift = (half_height * sin_theta) - (half_width * cos_theta) yshift = -(half_height * cos_theta) - (half_width * sin_theta) return np.atleast_2d(positions) + np.array([xshift, yshift]) class RectangularAperture(RectangularMaskMixin, PixelAperture): """ A rectangular aperture defined in pixel coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : array_like The pixel coordinates of the aperture center(s) in one of the following formats: * single ``(x, y)`` pair as a tuple, list, or `~numpy.ndarray` * tuple, list, or `~numpy.ndarray` of ``(x, y)`` pairs w : float The full width of the rectangle in pixels. For ``theta=0`` the width side is along the ``x`` axis. h : float The full height of the rectangle in pixels. For ``theta=0`` the height side is along the ``y`` axis. theta : float or `~astropy.units.Quantity`, optional The rotation angle as an angular quantity (`~astropy.units.Quantity` or `~astropy.coordinates.Angle`) or value in radians (as a float) from the positive ``x`` axis. The rotation angle increases counterclockwise. Raises ------ ValueError : `ValueError` If either width (``w``) or height (``h``) is negative. Examples -------- >>> from astropy.coordinates import Angle >>> from photutils.aperture import RectangularAperture >>> theta = Angle(80, 'deg') >>> aper = RectangularAperture([10.0, 20.0], 5.0, 3.0) >>> aper = RectangularAperture((10.0, 20.0), 5.0, 3.0, theta=theta) >>> pos1 = (10.0, 20.0) # (x, y) >>> pos2 = (30.0, 40.0) >>> pos3 = (50.0, 60.0) >>> aper = RectangularAperture([pos1, pos2, pos3], 5.0, 3.0) >>> aper = RectangularAperture((pos1, pos2, pos3), 5.0, 3.0, theta=theta) """ _params = ('positions', 'w', 'h', 'theta') positions = PixelPositions('The center pixel position(s).') w = PositiveScalar('The full width in pixels.') h = PositiveScalar('The full height in pixels.') theta = ScalarAngleOrValue('The counterclockwise rotation angle as an ' 'angular Quantity or value in radians from ' 'the positive x axis.') def __init__(self, positions, w, h, theta=0.0): self.positions = positions self.w = w self.h = h self.theta = theta @property def _xy_extents(self): return self._calc_extents(self.w, self.h, self.theta) @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 = self._lower_left_positions(xy_positions, self.w, self.h, self.theta) angle = self.theta.to(u.deg).value patches = [mpatches.Rectangle(xy_position, self.w, self.h, angle=angle, **patch_kwargs) for xy_position in xy_positions] if self.isscalar: return patches[0] return patches def to_mask(self, method='exact', subpixels=5): return RectangularMaskMixin.to_mask(self, method=method, subpixels=subpixels) def to_sky(self, wcs): """ Convert the aperture to a `SkyRectangularAperture` object defined in celestial coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `SkyRectangularAperture` object A `SkyRectangularAperture` object. """ return SkyRectangularAperture(**self._to_sky_params(wcs)) class RectangularAnnulus(RectangularMaskMixin, PixelAperture): r""" A rectangular annulus aperture defined in pixel coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : array_like The pixel coordinates of the aperture center(s) in one of the following formats: * single ``(x, y)`` pair as a tuple, list, or `~numpy.ndarray` * tuple, list, or `~numpy.ndarray` of ``(x, y)`` pairs w_in : float The inner full width of the rectangular annulus in pixels. For ``theta=0`` the width side is along the ``x`` axis. w_out : float The outer full width of the rectangular annulus in pixels. For ``theta=0`` the width side is along the ``x`` axis. h_out : float The outer full height of the rectangular annulus in pixels. h_in : `None` or float The inner full height of the rectangular annulus in pixels. If `None`, then the inner full height is calculated as: .. math:: h_{in} = h_{out} \left(\frac{w_{in}}{w_{out}}\right) For ``theta=0`` the height side is along the ``y`` axis. theta : float or `~astropy.units.Quantity`, optional The rotation angle as an angular quantity (`~astropy.units.Quantity` or `~astropy.coordinates.Angle`) or value in radians (as a float) from the positive ``x`` axis. The rotation angle increases counterclockwise. Raises ------ ValueError : `ValueError` If inner width (``w_in``) is greater than outer width (``w_out``). ValueError : `ValueError` If either the inner width (``w_in``) or the outer height (``h_out``) is negative. Examples -------- >>> from astropy.coordinates import Angle >>> from photutils.aperture import RectangularAnnulus >>> theta = Angle(80, 'deg') >>> aper = RectangularAnnulus([10.0, 20.0], 3.0, 8.0, 5.0) >>> aper = RectangularAnnulus((10.0, 20.0), 3.0, 8.0, 5.0, theta=theta) >>> pos1 = (10.0, 20.0) # (x, y) >>> pos2 = (30.0, 40.0) >>> pos3 = (50.0, 60.0) >>> aper = RectangularAnnulus([pos1, pos2, pos3], 3.0, 8.0, 5.0) >>> aper = RectangularAnnulus((pos1, pos2, pos3), 3.0, 8.0, 5.0, ... theta=theta) """ _params = ('positions', 'w_in', 'w_out', 'h_in', 'h_out', 'theta') positions = PixelPositions('The center pixel position(s).') w_in = PositiveScalar('The inner full width in pixels.') w_out = PositiveScalar('The outer full width in pixels.') h_in = PositiveScalar('The inner full height in pixels.') h_out = PositiveScalar('The outer full height in pixels.') theta = ScalarAngleOrValue('The counterclockwise rotation angle as an ' 'angular Quantity or value in radians from ' 'the positive x axis.') def __init__(self, positions, w_in, w_out, h_out, h_in=None, theta=0.0): if not w_out > w_in: raise ValueError('"w_out" must be greater than "w_in"') self.positions = positions self.w_in = w_in self.w_out = w_out self.h_out = h_out if h_in is None: h_in = self.w_in * self.h_out / self.w_out elif not h_out > h_in: raise ValueError('"h_out" must be greater than "h_in"') self.h_in = h_in self.theta = theta @property def _xy_extents(self): return self._calc_extents(self.w_out, self.h_out, self.theta) @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 = self._lower_left_positions(xy_positions, self.w_in, self.h_in, self.theta) outer_xy_positions = self._lower_left_positions(xy_positions, self.w_out, self.h_out, self.theta) patches = [] angle = self.theta.to(u.deg).value for xy_in, xy_out in zip(inner_xy_positions, outer_xy_positions, strict=True): patch_inner = mpatches.Rectangle(xy_in, self.w_in, self.h_in, angle=angle) patch_outer = mpatches.Rectangle(xy_out, self.w_out, self.h_out, angle=angle) path = self._make_annulus_path(patch_inner, patch_outer) patches.append(mpatches.PathPatch(path, **patch_kwargs)) if self.isscalar: return patches[0] return patches def to_mask(self, method='exact', subpixels=5): return RectangularMaskMixin.to_mask(self, method=method, subpixels=subpixels) def to_sky(self, wcs): """ Convert the aperture to a `SkyRectangularAnnulus` object defined in celestial coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `SkyRectangularAnnulus` object A `SkyRectangularAnnulus` object. """ return SkyRectangularAnnulus(**self._to_sky_params(wcs)) class SkyRectangularAperture(SkyAperture): """ A rectangular aperture defined in sky coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The celestial coordinates of the aperture center(s). This can be either scalar coordinates or an array of coordinates. w : scalar `~astropy.units.Quantity` The full width of the rectangle in angular units. For ``theta=0`` the width side is along the North-South axis. h : scalar `~astropy.units.Quantity` The full height of the rectangle in angular units. For ``theta=0`` the height side is along the East-West axis. theta : scalar `~astropy.units.Quantity`, optional The position angle (in angular units) of the rectangle "width" side. For a right-handed world coordinate system, the position angle increases counterclockwise from North (PA=0). Examples -------- >>> from astropy.coordinates import SkyCoord >>> import astropy.units as u >>> from photutils.aperture import SkyRectangularAperture >>> positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') >>> aper = SkyRectangularAperture(positions, 1.0*u.arcsec, 0.5*u.arcsec) """ _params = ('positions', 'w', 'h', 'theta') positions = SkyCoordPositions('The center position(s) in sky coordinates.') w = PositiveScalarAngle('The full width in angular units.') h = PositiveScalarAngle('The full height in angular units.') theta = ScalarAngle('The position angle (in angular units) of the ' 'rectangle "width" side.') def __init__(self, positions, w, h, theta=0.0 * u.deg): self.positions = positions self.w = w self.h = h self.theta = theta def to_pixel(self, wcs): """ Convert the aperture to a `RectangularAperture` object defined in pixel coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `RectangularAperture` object A `RectangularAperture` object. """ return RectangularAperture(**self._to_pixel_params(wcs)) class SkyRectangularAnnulus(SkyAperture): r""" A rectangular annulus aperture defined in sky coordinates. The aperture has a single fixed size/shape, but it can have multiple positions (see the ``positions`` input). Parameters ---------- positions : `~astropy.coordinates.SkyCoord` The celestial coordinates of the aperture center(s). This can be either scalar coordinates or an array of coordinates. w_in : scalar `~astropy.units.Quantity` The inner full width of the rectangular annulus in angular units. For ``theta=0`` the width side is along the North-South axis. w_out : scalar `~astropy.units.Quantity` The outer full width of the rectangular annulus in angular units. For ``theta=0`` the width side is along the North-South axis. h_out : scalar `~astropy.units.Quantity` The outer full height of the rectangular annulus in angular units. h_in : `None` or scalar `~astropy.units.Quantity` The outer full height of the rectangular annulus in angular units. If `None`, then the inner full height is calculated as: .. math:: h_{in} = h_{out} \left(\frac{w_{in}}{w_{out}}\right) For ``theta=0`` the height side is along the East-West axis. theta : scalar `~astropy.units.Quantity`, optional The position angle (in angular units) of the rectangle "width" side. For a right-handed world coordinate system, the position angle increases counterclockwise from North (PA=0). Examples -------- >>> from astropy.coordinates import SkyCoord >>> import astropy.units as u >>> from photutils.aperture import SkyRectangularAnnulus >>> positions = SkyCoord(ra=[10.0, 20.0], dec=[30.0, 40.0], unit='deg') >>> aper = SkyRectangularAnnulus(positions, 3.0*u.arcsec, 8.0*u.arcsec, ... 5.0*u.arcsec) """ _params = ('positions', 'w_in', 'w_out', 'h_in', 'h_out', 'theta') positions = SkyCoordPositions('The center position(s) in sky coordinates.') w_in = PositiveScalarAngle('The inner full width in angular units.') w_out = PositiveScalarAngle('The outer full width in angular units.') h_in = PositiveScalarAngle('The inner full height in angular units.') h_out = PositiveScalarAngle('The outer full height in angular units.') theta = ScalarAngle('The position angle (in angular units) of the ' 'rectangle "width" side.') def __init__(self, positions, w_in, w_out, h_out, h_in=None, theta=0.0 * u.deg): if not w_out > w_in: raise ValueError('"w_out" must be greater than "w_in".') self.positions = positions self.w_in = w_in self.w_out = w_out self.h_out = h_out if h_in is None: h_in = self.w_in * self.h_out / self.w_out elif not h_out > h_in: raise ValueError('"h_out" must be greater than "h_in".') self.h_in = h_in self.theta = theta def to_pixel(self, wcs): """ Convert the aperture to a `RectangularAnnulus` object defined in pixel coordinates. Parameters ---------- wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). Returns ------- aperture : `RectangularAnnulus` object A `RectangularAnnulus` object. """ return RectangularAnnulus(**self._to_pixel_params(wcs)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/stats.py0000644000175100001660000016530714755160622020567 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for calculating the properties of sources defined by an Aperture. """ import functools import inspect import warnings from copy import deepcopy import astropy.units as u import numpy as np from astropy.nddata import NDData, StdDevUncertainty from astropy.stats import (SigmaClip, biweight_location, biweight_midvariance, mad_std) from astropy.table import QTable from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture import Aperture, SkyAperture, region_to_aperture from photutils.aperture.core import _aperture_metadata from photutils.utils._misc import _get_meta from photutils.utils._moments import _moments, _moments_central from photutils.utils._quantity_helpers import process_quantities __all__ = ['ApertureStats'] # default table columns for `to_table()` output DEFAULT_COLUMNS = ['id', 'xcentroid', 'ycentroid', 'sky_centroid', 'sum', 'sum_err', 'sum_aper_area', 'center_aper_area', 'min', 'max', 'mean', 'median', 'mode', 'std', 'mad_std', 'var', 'biweight_location', 'biweight_midvariance', 'fwhm', 'semimajor_sigma', 'semiminor_sigma', 'orientation', 'eccentricity'] def as_scalar(method): """ Return a decorated method where it will always return a scalar value (instead of a length-1 tuple/list/array) if the class is scalar. Parameters ---------- method : function The method to be decorated. Returns ------- decorator : function The decorated method. """ @functools.wraps(method) def _decorator(*args, **kwargs): result = method(*args, **kwargs) try: return (result[0] if args[0].isscalar and len(result) == 1 else result) except TypeError: # if result has no len return result return _decorator class ApertureStats: """ Class to create a catalog of statistics for pixels within an aperture. Note that this class returns the statistics of the input ``data`` values within the aperture. It does not convert data in surface brightness units to flux or counts. Conversion from surface-brightness units should be performed before using this function. Parameters ---------- data : 2D `~numpy.ndarray`, `~astropy.units.Quantity`, \ `~astropy.nddata.NDData` The 2D array from which to calculate the source properties. For accurate source properties, ``data`` should be background-subtracted. Non-finite ``data`` values (NaN and inf) are automatically masked. aperture : `~photutils.aperture.Aperture` or supported `~regions.Region` The aperture or region to apply to the data. The aperture or region object may contain more than one position. If the input ``aperture`` is a `~photutils.aperture.SkyAperture` or `~regions.SkyRegion` object, then a WCS must be input using the ``wcs`` keyword. Region objects are converted to aperture objects. error : 2D `~numpy.ndarray` or `~astropy.units.Quantity`, optional The total error array corresponding to the input ``data`` array. ``error`` is assumed to include *all* sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. If ``data`` is a `~astropy.units.Quantity` array then ``error`` must be a `~astropy.units.Quantity` array (and vice versa) with identical units. Non-finite ``error`` values (NaN and +/- inf) are not automatically masked, unless they are at the same position of non-finite values in the input ``data`` array. Such pixels can be masked using the ``mask`` keyword. mask : 2D `~numpy.ndarray` (bool), optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. Non-finite values (NaN and inf) in the input ``data`` are automatically masked. wcs : WCS object or `None`, optional A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). ``wcs`` is required if the input ``aperture`` is a `~photutils.aperture.SkyAperture` or `~regions.SkyRegion` object. If `None`, then all sky-based properties will be set to `None`. sigma_clip : `None` or `astropy.stats.SigmaClip` instance, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. sum_method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid. This method is used only for calculating the ``sum``, ``sum_error``, ``sum_aper_area``, ``data_sumcutout``, and ``error_sumcutout`` properties. All other properties use the "center" aperture mask method. Not all options are available for all aperture types. The following methods are available: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``sum_method='subpixel'``. local_bkg : float, `~numpy.ndarray`, `~astropy.units.Quantity`, or `None` The per-pixel local background values to subtract from the data before performing measurements. If input as an array, the order of ``local_bkg`` values corresponds to the order of the input ``aperture`` positions. ``local_bkg`` must have the same length as the input ``aperture`` or must be a scalar value, which will be broadcast to all apertures. If `None`, then no local background subtraction is performed. If the input ``data`` has units, then ``local_bkg`` must be a `~astropy.units.Quantity` with the same units. Notes ----- ``data`` should be background-subtracted for accurate source properties. In addition to global background subtraction, local background subtraction can be performed using the ``local_bkg`` keyword values. `~regions.Region` objects are converted to `Aperture` objects using the :func:`region_to_aperture` function. Most source properties are calculated using the "center" aperture-mask method, which gives aperture weights of 0 or 1. This avoids the need to compute weighted statistics --- the ``data`` pixel values are directly used. The input ``sum_method`` and ``subpixels`` keywords are used to determine the aperture-mask method when calculating the sum-related properties: ``sum``, ``sum_error``, ``sum_aper_area``, ``data_sumcutout``, and ``error_sumcutout``. The default is ``sum_method='exact'``, which produces exact aperture-weighted photometry. .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ Examples -------- >>> from photutils.datasets import make_4gaussians_image >>> from photutils.aperture import CircularAperture, ApertureStats >>> data = make_4gaussians_image() >>> aper = CircularAperture((150, 25), 8) >>> aperstats = ApertureStats(data, aper) >>> print(aperstats.xcentroid) # doctest: +FLOAT_CMP 149.99080259251238 >>> print(aperstats.ycentroid) # doctest: +FLOAT_CMP 24.97484633000507 >>> print(aperstats.centroid) # doctest: +FLOAT_CMP [149.99080259 24.97484633] >>> print(aperstats.mean, aperstats.median) # doctest: +FLOAT_CMP 47.76300955780609 31.913789514433084 >>> print(aperstats.std) # doctest: +FLOAT_CMP 39.193655383492974 >>> print(aperstats.sum) # doctest: +FLOAT_CMP 9286.709206410273 >>> print(aperstats.sum_aper_area) # doctest: +FLOAT_CMP 201.0619298297468 pix2 >>> # more than one aperture position >>> aper2 = CircularAperture(((150, 25), (90, 60)), 10) >>> aperstats2 = ApertureStats(data, aper2) >>> print(aperstats2.xcentroid) # doctest: +FLOAT_CMP [149.98470724 89.97893946] >>> print(aperstats2.sum) # doctest: +FLOAT_CMP [10177.62548482 36653.97704059] """ def __init__(self, data, aperture, *, error=None, mask=None, wcs=None, sigma_clip=None, sum_method='exact', subpixels=5, local_bkg=None): if isinstance(data, NDData): data, error, mask, wcs = self._unpack_nddata(data, error, mask, wcs) inputs = (data, error, local_bkg) names = ('data', 'error', 'local_bkg') inputs, unit = process_quantities(inputs, names) (data, error, local_bkg) = inputs self._data = self._validate_array(data, 'data', shape=False) self._data_unit = unit self._input_aperture = self._validate_aperture(aperture) aperture_meta = _aperture_metadata(aperture) # use input aperture if isinstance(aperture, SkyAperture) and wcs is None: raise ValueError('A wcs is required when using a SkyAperture') # convert region to aperture if necessary if not isinstance(aperture, Aperture): aperture = region_to_aperture(aperture) self.aperture = aperture self._error = self._validate_array(error, 'error') self._mask = self._validate_array(mask, 'mask') self._wcs = wcs if sigma_clip is not None and not isinstance(sigma_clip, SigmaClip): raise TypeError('sigma_clip must be a SigmaClip instance') self.sigma_clip = sigma_clip self.sum_method = sum_method self.subpixels = subpixels self._local_bkg = np.zeros(self.n_apertures) # no local bkg if local_bkg is not None: local_bkg = np.atleast_1d(local_bkg) if local_bkg.ndim != 1: raise ValueError('local_bkg must be a 1D array') n_local_bkg = len(local_bkg) if n_local_bkg not in (1, self.n_apertures): raise ValueError('local_bkg must be scalar or have the same ' 'length as the input aperture') local_bkg = np.broadcast_to(local_bkg, self.n_apertures) if np.any(~np.isfinite(local_bkg)): raise ValueError('local_bkg must not contain any non-finite ' '(e.g., inf or NaN) values') self._local_bkg = local_bkg # always an iterable self._ids = np.arange(self.n_apertures) + 1 self.default_columns = DEFAULT_COLUMNS self.meta = _get_meta() self.meta.update(aperture_meta) @staticmethod def _unpack_nddata(data, error, mask, wcs): nddata_attr = {'error': error, 'mask': mask, 'wcs': wcs} for key, value in nddata_attr.items(): if value is not None: warnings.warn(f'The {key!r} keyword will be ignored. Its ' 'value is obtained from the input NDData ' 'object.', AstropyUserWarning) mask = data.mask wcs = data.wcs if isinstance(data.uncertainty, StdDevUncertainty): if data.uncertainty.unit is None: error = data.uncertainty.array else: error = data.uncertainty.array * data.uncertainty.unit if data.unit is not None: data = u.Quantity(data.data, unit=data.unit) else: data = data.data return data, error, mask, wcs @staticmethod def _validate_aperture(aperture): try: from regions import Region aper_types = (Aperture, Region) except ImportError: aper_types = Aperture if not isinstance(aperture, aper_types): raise TypeError('aperture must be an Aperture or Region object') return aperture def _validate_array(self, array, name, ndim=2, shape=True): if name == 'mask' and array is np.ma.nomask: array = None if array is not None: array = np.asanyarray(array) if array.ndim != ndim: raise ValueError(f'{name} must be a {ndim}D array.') if shape and array.shape != self._data.shape: raise ValueError(f'data and {name} must have the same shape.') return array @property def _lazyproperties(self): """ A list of all class lazyproperties (even in superclasses). """ def islazyproperty(obj): return isinstance(obj, lazyproperty) return [i[0] for i in inspect.getmembers(self.__class__, predicate=islazyproperty)] @property def properties(self): """ A sorted list of built-in source properties. """ lazyproperties = [name for name in self._lazyproperties if not name.startswith('_')] lazyproperties.sort() return lazyproperties def __getitem__(self, index): if self.isscalar: raise TypeError(f'A scalar {self.__class__.__name__!r} object ' 'cannot be indexed') newcls = object.__new__(self.__class__) # attributes defined in __init__ that are copied directly to the # new class init_attr = ('_data', '_data_unit', '_error', '_mask', '_wcs', 'sigma_clip', 'sum_method', 'subpixels', 'default_columns', 'meta') for attr in init_attr: setattr(newcls, attr, getattr(self, attr)) # need to slice _aperture and _ids; # aperture determines isscalar (needed below) attrs = ('aperture', '_ids') for attr in attrs: setattr(newcls, attr, getattr(self, attr)[index]) # slice evaluated lazyproperty objects keys = set(self.__dict__.keys()) & set(self._lazyproperties) keys.add('_local_bkg') # iterable defined in __init__ for key in keys: value = self.__dict__[key] # do not insert attributes that are always scalar (e.g., # isscalar, n_apertures), i.e., not an array/list for each # source if np.isscalar(value): continue try: # keep most _ as length-1 iterables if (newcls.isscalar and key.startswith('_') and key != '_pixel_aperture'): if isinstance(value, np.ndarray): val = value[:, np.newaxis][index] else: val = [value[index]] else: val = value[index] except TypeError: # apply fancy indices (e.g., array/list or bool # mask) to lists # see https://numpy.org/doc/stable/release/1.20.0-notes.html # #arraylike-objects-which-do-not-define-len-and-getitem arr = np.empty(len(value), dtype=object) arr[:] = list(value) val = arr[index].tolist() newcls.__dict__[key] = val return newcls def __str__(self): cls_name = f'<{self.__class__.__module__}.{self.__class__.__name__}>' with np.printoptions(threshold=25, edgeitems=5): fmt = [f'Length: {self.n_apertures}'] return f'{cls_name}\n' + '\n'.join(fmt) def __repr__(self): return self.__str__() def __len__(self): if self.isscalar: raise TypeError(f'Scalar {self.__class__.__name__!r} object has ' 'no len()') return self.n_apertures def __iter__(self): for item in range(len(self)): yield self.__getitem__(item) @lazyproperty def isscalar(self): """ Whether the instance is scalar (e.g., a single aperture position). """ return self._pixel_aperture.isscalar def copy(self): """ Return a deep copy of this object. Returns ------- result : `ApertureStats` A deep copy of this object. """ return deepcopy(self) @lazyproperty def _null_object(self): """ Return `None` values. """ return np.array([None] * self.n_apertures) @lazyproperty def _null_value(self): """ Return np.nan values. """ values = np.empty(self.n_apertures) values.fill(np.nan) return values @property @as_scalar def id(self): """ The aperture identification number(s). """ return self._ids @property def ids(self): """ The aperture identification number(s), always as an iterable `~numpy.ndarray`. """ _ids = self._ids if self.isscalar: _ids = np.array((_ids,)) return _ids def get_id(self, id_num): """ Return a new `ApertureStats` object for the input ID number only. Parameters ---------- id_num : int The aperture ID number. Returns ------- result : `ApertureStats` A new `ApertureStats` object containing only the source with the input ID number. """ return self.get_ids(id_num) def get_ids(self, id_nums): """ Return a new `ApertureStats` object for the input ID numbers only. Parameters ---------- id_nums : list, tuple, or `~numpy.ndarray` of int The aperture ID number(s). Returns ------- result : `ApertureStats` A new `ApertureStats` object containing only the sources with the input ID numbers. """ for id_num in np.atleast_1d(id_nums): if id_num not in self.ids: raise ValueError(f'{id_num} is not a valid source ID number') sorter = np.argsort(self.id) indices = sorter[np.searchsorted(self.id, id_nums, sorter=sorter)] return self[indices] def to_table(self, columns=None): """ Create a `~astropy.table.QTable` of source properties. Parameters ---------- columns : str, list of str, `None`, optional Names of columns, in order, to include in the output `~astropy.table.QTable`. The allowed column names are any of the `ApertureStats` properties. If ``columns`` is `None`, then a default list of scalar-valued properties (as defined by the ``default_columns`` attribute) will be used. Returns ------- table : `~astropy.table.QTable` A table of sources properties with one row per source. """ if columns is None: table_columns = self.default_columns elif isinstance(columns, str): table_columns = [columns] else: table_columns = columns tbl = QTable() tbl.meta.update(self.meta) # keep tbl.meta type for column in table_columns: values = getattr(self, column) # column assignment requires an object with a length if self.isscalar: values = (values,) tbl[column] = values return tbl @lazyproperty def n_apertures(self): """ The number of positions in the input aperture. """ if self.isscalar: return 1 return len(self._pixel_aperture) @lazyproperty def _pixel_aperture(self): """ The input aperture as a PixelAperture. """ if isinstance(self.aperture, SkyAperture): return self.aperture.to_pixel(self._wcs) return self.aperture @lazyproperty def _aperture_masks_center(self): """ The aperture masks (`ApertureMask`) generated with the 'center' method, always as an iterable. """ aperture_masks = self._pixel_aperture.to_mask(method='center') if self.isscalar: aperture_masks = (aperture_masks,) return aperture_masks @lazyproperty def _aperture_masks(self): """ The aperture masks (`ApertureMask`) generated with the ``sum_method`` method, always as an iterable. """ aperture_masks = self._pixel_aperture.to_mask(method=self.sum_method, subpixels=self.subpixels) if self.isscalar: aperture_masks = (aperture_masks,) return aperture_masks @lazyproperty def _overlap_slices(self): """ The aperture mask overlap slices with the data, always as an iterable. The overlap slices are the same for all aperture mask methods. """ overlap_slices = [] for apermask in self._aperture_masks_center: (slc_large, slc_small) = apermask.get_overlap_slices( self._data.shape) overlap_slices.append((slc_large, slc_small)) return overlap_slices @lazyproperty def _data_cutouts(self): """ The local-background-subtracted unmasked data cutouts using the aperture bounding box, always as a iterable. """ cutouts = [] for (slices, local_bkg) in zip(self._overlap_slices, self._local_bkg, strict=True): if slices[0] is None: cutout = None # no aperture overlap with the data else: # copy is needed to preserve input data because masks are # applied to these cutouts later cutout = (self._data[slices[0]].astype(float, copy=True) - local_bkg) cutouts.append(cutout) return cutouts def _make_aperture_cutouts(self, aperture_masks): """ Make aperture-weighted cutouts for the data and variance, and cutouts for the total mask and aperture mask weights. Parameters ---------- aperture_masks : list of `ApertureMask` A list of `ApertureMask` objects. Returns ------- data, variance, mask, weights : list of `~numpy.ndarray` A list of cutout arrays for the data, variance, mask and weight arrays for each source (aperture position). """ data_cutouts = [] variance_cutouts = [] mask_cutouts = [] weight_cutouts = [] overlaps = [] for (data_cutout, apermask, slices) in zip(self._data_cutouts, aperture_masks, self._overlap_slices, strict=True): slc_large, slc_small = slices if slc_large is None: # aperture does not overlap the data overlap = False data_cutout = np.array([np.nan]) variance_cutout = np.array([np.nan]) mask_cutout = np.array([False]) weight_cutout = np.array([np.nan]) else: # create a mask of non-finite ``data`` values combined # with the input ``mask`` array. data_mask = ~np.isfinite(data_cutout) if self._mask is not None: data_mask |= self._mask[slc_large] overlap = True aperweight_cutout = apermask.data[slc_small] weight_cutout = aperweight_cutout * ~data_mask # apply the aperture mask; for "exact" and "subpixel" # this is an expanded boolean mask using the aperture # mask zero values mask_cutout = (aperweight_cutout == 0) | data_mask data_cutout = data_cutout.copy() if self.sigma_clip is None: # data_cutout will have zeros where mask_cutout is True data_cutout *= ~mask_cutout else: # to input a mask, SigmaClip needs a MaskedArray data_cutout_ma = np.ma.masked_array(data_cutout, mask=mask_cutout) data_sigclip = self.sigma_clip(data_cutout_ma) # define a mask of only the sigma-clipped pixels sigclip_mask = data_sigclip.mask & ~mask_cutout weight_cutout *= ~sigclip_mask mask_cutout = data_sigclip.mask data_cutout = data_sigclip.filled(0.0) # need to apply the aperture weights data_cutout *= aperweight_cutout if self._error is None: variance_cutout = None else: # apply the exact weights and total mask; # error_cutout will have zeros where mask_cutout is True variance = self._error[slc_large]**2 variance_cutout = (variance * aperweight_cutout * ~mask_cutout) data_cutouts.append(data_cutout) variance_cutouts.append(variance_cutout) mask_cutouts.append(mask_cutout) weight_cutouts.append(weight_cutout) overlaps.append(overlap) # use zip (instead of np.transpose) because these may contain # arrays that have different shapes return list(zip(data_cutouts, variance_cutouts, mask_cutouts, weight_cutouts, overlaps, strict=True)) @lazyproperty def _aperture_cutouts_center(self): """ Aperture-weighted cutouts for the data, variance, total mask, and aperture weights using the "center" aperture mask method. """ return self._make_aperture_cutouts(self._aperture_masks_center) @lazyproperty def _aperture_cutouts(self): """ Aperture-weighted cutouts for the data, variance, total mask, and aperture weights using the input ``sum_method`` aperture mask method. """ return self._make_aperture_cutouts(self._aperture_masks) @lazyproperty def _mask_cutout_center(self): """ Boolean mask cutouts representing the total mask. The total mask is combination of the input ``mask``, non-finite ``data`` values, the cutout aperture mask using the "center" method, and the sigma-clip mask. """ return list(zip(*self._aperture_cutouts_center, strict=True))[2] @lazyproperty def _mask_cutout(self): """ Boolean mask cutouts representing the total mask. The total mask is combination of the input ``mask``, non-finite ``data`` values, the cutout aperture mask using the ``sum_method`` method, and the sigma-clip mask. """ return list(zip(*self._aperture_cutouts, strict=True))[2] def _make_masked_array_center(self, array): """ Return a list of cutout masked arrays using the ``_mask_cutout`` mask. Units are not applied. """ return [np.ma.masked_array(arr, mask=mask) for arr, mask in zip(array, self._mask_cutout_center, strict=True)] def _make_masked_array(self, array): """ Return a list of cutout masked arrays using the ``_mask_sumcutout`` mask. Units are not applied. """ return [np.ma.masked_array(arr, mask=mask) for arr, mask in zip(array, self._mask_cutout, strict=True)] @lazyproperty @as_scalar def data_cutout(self): """ A 2D aperture-weighted cutout from the data using the aperture mask with the "center" method as a `~numpy.ma.MaskedArray`. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. """ return self._make_masked_array_center( list(zip(*self._aperture_cutouts_center, strict=True))[0]) @lazyproperty @as_scalar def data_sumcutout(self): """ A 2D aperture-weighted cutout from the data using the aperture mask with the input ``sum_method`` method as a `~numpy.ma.MaskedArray`. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. """ return self._make_masked_array(list(zip(*self._aperture_cutouts, strict=True))[0]) @lazyproperty def _variance_cutout_center(self): """ A 2D aperture-weighted variance cutout using the aperture mask with the input "center" method as a `~numpy.ma.MaskedArray`. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. """ if self._error is None: return self._null_object return self._make_masked_array_center( list(zip(*self._aperture_cutouts_center, strict=True))[1]) @lazyproperty def _variance_cutout(self): """ A 2D aperture-weighted variance cutout using the aperture mask with the input ``sum_method`` method as a `~numpy.ma.MaskedArray`. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. """ if self._error is None: return self._null_object return self._make_masked_array(list(zip(*self._aperture_cutouts, strict=True))[1]) @lazyproperty @as_scalar def error_sumcutout(self): """ A 2D aperture-weighted error cutout using the aperture mask with the input ``sum_method`` method as a `~numpy.ma.MaskedArray`. The cutout does not have units due to current limitations of masked quantity arrays. The mask is `True` for pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), sigma-clipped pixels within the aperture, and pixels where the aperture mask has zero weight. """ if self._error is None: return self._null_object return [np.sqrt(var) for var in self._variance_cutout] @lazyproperty def _weight_cutout_center(self): """ A 2D `~numpy.ma.MaskedArray` cutout from the aperture mask weights array using the aperture bounding box. The aperture mask weights are for the "center" method. The mask is `True` for pixels outside of the aperture mask, pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), and sigma-clipped pixels. """ return self._make_masked_array_center( list(zip(*self._aperture_cutouts_center, strict=True))[3]) @lazyproperty def _weight_cutout(self): """ A 2D `~numpy.ma.MaskedArray` cutout from the aperture mask weights array using the aperture bounding box. The aperture mask weights are for the ``sum_method`` method. The mask is `True` for pixels outside of the aperture mask, pixels from the input ``mask``, non-finite ``data`` values (NaN and inf), and sigma-clipped pixels. """ return self._make_masked_array(list(zip(*self._aperture_cutouts, strict=True))[3]) @lazyproperty def _moment_data_cutout(self): """ A list of 2D `~numpy.ndarray` cutouts from the data. Masked pixels are set to zero in these arrays (zeros do not contribute to the image moments). The aperture mask weights are for the "center" method. These arrays are used to derive moment-based properties. """ data = deepcopy(self.data_cutout) # self.data_cutout is a list if self.isscalar: data = (data,) cutouts = [] for arr in data: if arr.size == 1 and np.isnan(arr[0]): # no aperture overlap arr_ = np.empty((2, 2)) arr_.fill(np.nan) else: arr_ = arr.data arr_[arr.mask] = 0.0 cutouts.append(arr_) return cutouts @lazyproperty def _all_masked(self): """ True if all pixels within the aperture are masked. """ return np.array([np.all(mask) for mask in self._mask_cutout_center]) @lazyproperty def _overlap(self): """ True if there is no overlap of the aperture with the data. """ return list(zip(*self._aperture_cutouts_center, strict=True))[4] def _get_values(self, array): """ Get a 1D array of unmasked aperture-weighted values from the input array. An array with a single NaN is returned for completely-masked sources. """ if self.isscalar: array = (array,) return [arr.compressed() if len(arr.compressed()) > 0 else np.array([np.nan]) for arr in array] @lazyproperty def _data_values_center(self): """ A 1D array of unmasked aperture-weighted data values using the "center" method. An array with a single NaN is returned for completely-masked sources. """ return self._get_values(self.data_cutout) @lazyproperty @as_scalar def moments(self): """ Spatial moments up to 3rd order of the source. """ return np.array([_moments(arr, order=3) for arr in self._moment_data_cutout]) @lazyproperty @as_scalar def moments_central(self): """ Central moments (translation invariant) of the source up to 3rd order. """ cutout_centroid = self.cutout_centroid if self.isscalar: cutout_centroid = cutout_centroid[np.newaxis, :] return np.array([_moments_central(arr, center=(xcen_, ycen_), order=3) for arr, xcen_, ycen_ in zip(self._moment_data_cutout, cutout_centroid[:, 0], cutout_centroid[:, 1], strict=True)]) @lazyproperty @as_scalar def cutout_centroid(self): """ The ``(x, y)`` coordinate, relative to the cutout data, of the centroid within the aperture. The centroid is computed as the center of mass of the unmasked pixels within the aperture. """ moments = self.moments if self.isscalar: moments = moments[np.newaxis, :] # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) ycentroid = moments[:, 1, 0] / moments[:, 0, 0] xcentroid = moments[:, 0, 1] / moments[:, 0, 0] return np.transpose((xcentroid, ycentroid)) @lazyproperty @as_scalar def centroid(self): """ The ``(x, y)`` coordinate of the centroid. The centroid is computed as the center of mass of the unmasked pixels within the aperture. """ origin = np.transpose((self.bbox_xmin, self.bbox_ymin)) return self.cutout_centroid + origin @lazyproperty def _xcentroid(self): """ The ``x`` coordinate of the centroid, always as an iterable. """ xcentroid = np.transpose(self.centroid)[0] if self.isscalar: xcentroid = (xcentroid,) return xcentroid @lazyproperty @as_scalar def xcentroid(self): """ The ``x`` coordinate of the centroid. The centroid is computed as the center of mass of the unmasked pixels within the aperture. """ return self._xcentroid @lazyproperty def _ycentroid(self): """ The ``y`` coordinate of the centroid, always as an iterable. """ ycentroid = np.transpose(self.centroid)[1] if self.isscalar: ycentroid = (ycentroid,) return ycentroid @lazyproperty @as_scalar def ycentroid(self): """ The ``y`` coordinate of the centroid. The centroid is computed as the center of mass of the unmasked pixels within the aperture. """ return self._ycentroid @lazyproperty @as_scalar def sky_centroid(self): """ The sky coordinate of the centroid of the unmasked pixels within the aperture, returned as a `~astropy.coordinates.SkyCoord` object. The output coordinate frame is the same as the input ``wcs``. `None` if ``wcs`` is not input. """ if self._wcs is None: return self._null_object return self._wcs.pixel_to_world(self.xcentroid, self.ycentroid) @lazyproperty @as_scalar def sky_centroid_icrs(self): """ The sky coordinate in the International Celestial Reference System (ICRS) frame of the centroid of the unmasked pixels within the aperture, returned as a `~astropy.coordinates.SkyCoord` object. `None` if ``wcs`` is not input. """ if self._wcs is None: return self._null_object return self.sky_centroid.icrs @lazyproperty def _bbox(self): """ The `~photutils.aperture.BoundingBox` of the aperture, always as an iterable. """ apertures = self._pixel_aperture if self.isscalar: apertures = (apertures,) return [aperture.bbox for aperture in apertures] @lazyproperty @as_scalar def bbox(self): """ The `~photutils.aperture.BoundingBox` of the aperture. Note that the aperture bounding box is calculated using the exact size of the aperture, which may be slightly larger than the aperture mask calculated using the "center" mode. """ return self._bbox @lazyproperty @as_scalar def _bbox_bounds(self): """ The bounding box x/y minimum and maximum bounds. """ bbox = self.bbox if self.isscalar: bbox = (bbox,) return np.array([(bbox_.ixmin, bbox_.ixmax - 1, bbox_.iymin, bbox_.iymax - 1) for bbox_ in bbox]) @lazyproperty @as_scalar def bbox_xmin(self): """ The minimum ``x`` pixel index of the bounding box. """ return np.transpose(self._bbox_bounds)[0] @lazyproperty @as_scalar def bbox_xmax(self): """ The maximum ``x`` pixel index of the bounding box. Note that this value is inclusive, unlike numpy slice indices. """ return np.transpose(self._bbox_bounds)[1] @lazyproperty @as_scalar def bbox_ymin(self): """ The minimum ``y`` pixel index of the bounding box. """ return np.transpose(self._bbox_bounds)[2] @lazyproperty @as_scalar def bbox_ymax(self): """ The maximum ``y`` pixel index of the bounding box. Note that this value is inclusive, unlike numpy slice indices. """ return np.transpose(self._bbox_bounds)[3] def _calculate_stats(self, stat_func, unit=None): """ Apply the input ``stat_func`` to the 1D array of unmasked data values in the aperture. Units are applied if the input ``data`` has units. Parameters ---------- stat_func : callable The callable to apply to the 1D `~numpy.ndarray` of unmasked data values. unit : `None` or `astropy.unit.Unit`, optional The unit to apply to the output data. This is used only if the input ``data`` has units. If `None` then the input ``data`` unit will be used. """ result = np.array([stat_func(arr) for arr in self._data_values_center]) if unit is None: unit = self._data_unit if unit is not None: result <<= unit return result @lazyproperty @as_scalar def center_aper_area(self): """ The total area of the unmasked pixels within the aperture using the "center" aperture mask method. """ areas = np.array([np.sum(weight.filled(0.0)) for weight in self._weight_cutout_center]) areas[self._all_masked] = np.nan return areas << (u.pix**2) @lazyproperty @as_scalar def sum_aper_area(self): """ The total area of the unmasked pixels within the aperture using the input ``sum_method`` aperture mask method. """ areas = np.array([np.sum(weight.filled(0.0)) for weight in self._weight_cutout]) areas[self._all_masked] = np.nan return areas << (u.pix**2) @lazyproperty @as_scalar def sum(self): r""" The sum of the unmasked ``data`` values within the aperture. .. math:: F = \sum_{i \in A} I_i where :math:`F` is ``sum``, :math:`I_i` is the background-subtracted ``data``, and :math:`A` are the unmasked pixels in the aperture. Non-finite pixel values (NaN and inf) are excluded (automatically masked). """ if self.sum_method == 'center': return self._calculate_stats(np.sum) data_values = self._get_values(self.data_sumcutout) result = np.array([np.sum(arr) for arr in data_values]) if self._data_unit is not None: result <<= self._data_unit return result @lazyproperty @as_scalar def sum_err(self): r""" The uncertainty of `sum` , propagated from the input ``error`` array. ``sum_err`` is the quadrature sum of the total errors over the unmasked pixels within the aperture: .. math:: \Delta F = \sqrt{\sum_{i \in A} \sigma_{\mathrm{tot}, i}^2} where :math:`\Delta F` is the `sum_err`, :math:`\sigma_{\mathrm{tot, i}}` are the pixel-wise total errors (``error``), and :math:`A` are the unmasked pixels in the aperture. Pixel values that are masked in the input ``data``, including any non-finite pixel values (NaN and inf) that are automatically masked, are also masked in the error array. """ if self._error is None: err = self._null_value else: if self.sum_method == 'center': variance = self._variance_cutout_center else: variance = self._variance_cutout var_values = [arr.compressed() if len(arr.compressed()) > 0 else np.array([np.nan]) for arr in variance] err = np.sqrt([np.sum(arr) for arr in var_values]) if self._data_unit is not None: err <<= self._data_unit return err @lazyproperty @as_scalar def min(self): """ The minimum of the unmasked pixel values within the aperture. """ return self._calculate_stats(np.min) @lazyproperty @as_scalar def max(self): """ The maximum of the unmasked pixel values within the aperture. """ return self._calculate_stats(np.max) @lazyproperty @as_scalar def mean(self): """ The mean of the unmasked pixel values within the aperture. """ return self._calculate_stats(np.mean) @lazyproperty @as_scalar def median(self): """ The median of the unmasked pixel values within the aperture. """ return self._calculate_stats(np.median) @lazyproperty @as_scalar def mode(self): """ The mode of the unmasked pixel values within the aperture. The mode is estimated as ``(3 * median) - (2 * mean)``. """ return 3.0 * self.median - 2.0 * self.mean @lazyproperty @as_scalar def std(self): """ The standard deviation of the unmasked pixel values within the aperture. """ return self._calculate_stats(np.std) @lazyproperty @as_scalar def mad_std(self): r""" The standard deviation calculated using the `median absolute deviation (MAD) `_. The standard deviation estimator is given by: .. math:: \sigma \approx \frac{\textrm{MAD}}{\Phi^{-1}(3/4)} \approx 1.4826 \ \textrm{MAD} where :math:`\Phi^{-1}(P)` is the normal inverse cumulative distribution function evaluated at probability :math:`P = 3/4`. """ return self._calculate_stats(mad_std) @lazyproperty @as_scalar def var(self): """ The variance of the unmasked pixel values within the aperture. """ unit = self._data_unit if unit is not None: unit **= 2 return self._calculate_stats(np.var, unit=unit) @lazyproperty @as_scalar def biweight_location(self): """ The biweight location of the unmasked pixel values within the aperture. See `astropy.stats.biweight_location`. """ return self._calculate_stats(biweight_location) @lazyproperty @as_scalar def biweight_midvariance(self): """ The biweight midvariance of the unmasked pixel values within the aperture. See `astropy.stats.biweight_midvariance` """ unit = self._data_unit if unit is not None: unit **= 2 return self._calculate_stats(biweight_midvariance, unit=unit) @lazyproperty @as_scalar def inertia_tensor(self): """ The inertia tensor of the source for the rotation around its center of mass. """ moments = self.moments_central if self.isscalar: moments = moments[np.newaxis, :] mu_02 = moments[:, 0, 2] mu_11 = -moments[:, 1, 1] mu_20 = moments[:, 2, 0] tensor = np.array([mu_02, mu_11, mu_11, mu_20]).swapaxes(0, 1) return tensor.reshape((tensor.shape[0], 2, 2)) * u.pix**2 @lazyproperty def _covariance(self): """ The covariance matrix of the 2D Gaussian function that has the same second-order moments as the source, always as an iterable. """ moments = self.moments_central if self.isscalar: moments = moments[np.newaxis, :] # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) mu_norm = moments / moments[:, 0, 0][:, np.newaxis, np.newaxis] covar = np.array([mu_norm[:, 0, 2], mu_norm[:, 1, 1], mu_norm[:, 1, 1], mu_norm[:, 2, 0]]).swapaxes(0, 1) covar = covar.reshape((covar.shape[0], 2, 2)) # Modify the covariance matrix in the case of "infinitely" thin # detections. This follows SourceExtractor's prescription of # incrementally increasing the diagonal elements by 1/12. delta = 1.0 / 12 delta2 = delta**2 # ignore RuntimeWarning from NaN values in covar with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) covar_det = np.linalg.det(covar) # covariance should be positive semidefinite idx = np.where(covar_det < 0)[0] covar[idx] = np.array([[np.nan, np.nan], [np.nan, np.nan]]) idx = np.where(covar_det < delta2)[0] while idx.size > 0: covar[idx, 0, 0] += delta covar[idx, 1, 1] += delta covar_det = np.linalg.det(covar) idx = np.where(covar_det < delta2)[0] return covar @lazyproperty @as_scalar def covariance(self): """ The covariance matrix of the 2D Gaussian function that has the same second-order moments as the source. """ return self._covariance * (u.pix**2) @lazyproperty @as_scalar def covariance_eigvals(self): """ The two eigenvalues of the `covariance` matrix in decreasing order. """ eigvals = np.empty((self.n_apertures, 2)) eigvals.fill(np.nan) # np.linalg.eivals requires finite input values idx = np.unique(np.where(np.isfinite(self._covariance))[0]) eigvals[idx] = np.linalg.eigvals(self._covariance[idx]) # check for negative variance # (just in case covariance matrix is not positive semidefinite) idx2 = np.unique(np.where(eigvals < 0)[0]) # pragma: no cover eigvals[idx2] = (np.nan, np.nan) # pragma: no cover # sort each eigenvalue pair in descending order eigvals.sort(axis=1) eigvals = np.fliplr(eigvals) return eigvals * u.pix**2 @lazyproperty @as_scalar def semimajor_sigma(self): """ The 1-sigma standard deviation along the semimajor axis of the 2D Gaussian function that has the same second-order central moments as the source. """ eigvals = self.covariance_eigvals if self.isscalar: eigvals = eigvals[np.newaxis, :] # this matches SourceExtractor's A parameter return np.sqrt(eigvals[:, 0]) @lazyproperty @as_scalar def semiminor_sigma(self): """ The 1-sigma standard deviation along the semiminor axis of the 2D Gaussian function that has the same second-order central moments as the source. """ eigvals = self.covariance_eigvals if self.isscalar: eigvals = eigvals[np.newaxis, :] # this matches SourceExtractor's B parameter return np.sqrt(eigvals[:, 1]) @lazyproperty @as_scalar def fwhm(self): r""" The circularized full width at half maximum (FWHM) of the 2D Gaussian function that has the same second-order central moments as the source. .. math:: \mathrm{FWHM} & = 2 \sqrt{2 \ln(2)} \sqrt{0.5 (a^2 + b^2)} \\ & = 2 \sqrt{\ln(2) \ (a^2 + b^2)} where :math:`a` and :math:`b` are the 1-sigma lengths of the semimajor (`semimajor_sigma`) and semiminor (`semiminor_sigma`) axes, respectively. """ return 2.0 * np.sqrt(np.log(2.0) * (self.semimajor_sigma**2 + self.semiminor_sigma**2)) @lazyproperty @as_scalar def orientation(self): """ The angle between the ``x`` axis and the major axis of the 2D Gaussian function that has the same second-order moments as the source. The angle increases in the counter-clockwise direction. """ covar = self._covariance orient_radians = 0.5 * np.arctan2(2.0 * covar[:, 0, 1], (covar[:, 0, 0] - covar[:, 1, 1])) return np.rad2deg(orient_radians) * u.deg @lazyproperty @as_scalar def eccentricity(self): r""" The eccentricity of the 2D Gaussian function that has the same second-order moments as the source. The eccentricity is the fraction of the distance along the semimajor axis at which the focus lies. .. math:: e = \sqrt{1 - \frac{b^2}{a^2}} where :math:`a` and :math:`b` are the lengths of the semimajor and semiminor axes, respectively. """ semimajor_var, semiminor_var = np.transpose(self.covariance_eigvals) return np.sqrt(1.0 - (semiminor_var / semimajor_var)) @lazyproperty @as_scalar def elongation(self): r""" The ratio of the lengths of the semimajor and semiminor axes. .. math:: \mathrm{elongation} = \frac{a}{b} where :math:`a` and :math:`b` are the lengths of the semimajor and semiminor axes, respectively. """ return self.semimajor_sigma / self.semiminor_sigma @lazyproperty @as_scalar def ellipticity(self): r""" 1.0 minus the ratio of the lengths of the semimajor and semiminor axes (or 1.0 minus the `elongation`). .. math:: \mathrm{ellipticity} = 1 - \frac{b}{a} where :math:`a` and :math:`b` are the lengths of the semimajor and semiminor axes, respectively. """ return 1.0 - (self.semiminor_sigma / self.semimajor_sigma) @lazyproperty @as_scalar def covar_sigx2(self): r""" The ``(0, 0)`` element of the `covariance` matrix, representing :math:`\sigma_x^2`, in units of pixel**2. """ return self._covariance[:, 0, 0] * u.pix**2 @lazyproperty @as_scalar def covar_sigy2(self): r""" The ``(1, 1)`` element of the `covariance` matrix, representing :math:`\sigma_y^2`, in units of pixel**2. """ return self._covariance[:, 1, 1] * u.pix**2 @lazyproperty @as_scalar def covar_sigxy(self): r""" The ``(0, 1)`` and ``(1, 0)`` elements of the `covariance` matrix, representing :math:`\sigma_x \sigma_y`, in units of pixel**2. """ return self._covariance[:, 0, 1] * u.pix**2 @lazyproperty @as_scalar def cxx(self): r""" 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_sigma)**2 + (np.sin(self.orientation) / self.semiminor_sigma)**2) @lazyproperty @as_scalar def 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_sigma)**2 + (np.cos(self.orientation) / self.semiminor_sigma)**2) @lazyproperty @as_scalar def 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_sigma**2) - (1.0 / self.semiminor_sigma**2))) @lazyproperty @as_scalar def gini(self): r""" The `Gini coefficient `_ of the unmasked pixel values within the aperture. The Gini coefficient is calculated using the prescription from `Lotz et al. 2004 `_ as: .. math:: G = \frac{1}{\left | \bar{x} \right | n (n - 1)} \sum^{n}_{i} (2i - n - 1) \left | x_i \right | where :math:`\bar{x}` is the mean over pixel values :math:`x_i` within the aperture. The Gini coefficient is a way of measuring the inequality in a given set of values. In the context of galaxy morphology, it measures how the light of a galaxy image is distributed among its pixels. A Gini coefficient value of 0 corresponds to a galaxy image with the light evenly distributed over all pixels while a Gini coefficient value of 1 represents a galaxy image with all its light concentrated in just one pixel. """ gini = [] for arr in self._data_values_center: if np.all(np.isnan(arr)): gini.append(np.nan) continue npix = np.size(arr) normalization = np.abs(np.mean(arr)) * npix * (npix - 1) kernel = ((2.0 * np.arange(1, npix + 1) - npix - 1) * np.abs(np.sort(arr))) gini.append(np.sum(kernel) / normalization) return np.array(gini) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6929266 photutils-2.2.0/photutils/aperture/tests/0000755000175100001660000000000014755160634020210 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/tests/__init__.py0000644000175100001660000000000014755160622022304 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/tests/test_aperture_common.py0000644000175100001660000000406214755160622025017 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides base classes for aperture tests. """ from astropy.coordinates import SkyCoord from astropy.tests.helper import assert_quantity_allclose from numpy.testing import assert_equal class BaseTestApertureParams: index = 2 slc = slice(0, 2) expected_slc_len = 2 class BaseTestAperture(BaseTestApertureParams): def test_index(self): aper = self.aperture[self.index] assert isinstance(aper, self.aperture.__class__) assert aper.isscalar expected_positions = self.aperture.positions[self.index] for param in aper._params: if param == 'positions': if isinstance(expected_positions, SkyCoord): assert_quantity_allclose(aper.positions.ra, expected_positions.ra) assert_quantity_allclose(aper.positions.dec, expected_positions.dec) else: assert_equal(getattr(aper, param), expected_positions) else: assert (getattr(aper, param) == getattr(self.aperture, param)) def test_slice(self): aper = self.aperture[self.slc] assert isinstance(aper, self.aperture.__class__) assert len(aper) == self.expected_slc_len expected_positions = self.aperture.positions[self.slc] for param in aper._params: if param == 'positions': if isinstance(expected_positions, SkyCoord): assert_quantity_allclose(aper.positions.ra, expected_positions.ra) assert_quantity_allclose(aper.positions.dec, expected_positions.dec) else: assert_equal(getattr(aper, param), expected_positions) else: assert (getattr(aper, param) == getattr(self.aperture, param)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/tests/test_bounding_box.py0000644000175100001660000001241614755160622024277 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the bounding_box module. """ import pytest from numpy.testing import assert_allclose from photutils.aperture.bounding_box import BoundingBox from photutils.aperture.rectangle import RectangularAperture from photutils.utils._optional_deps import HAS_MATPLOTLIB def test_bounding_box_init(): bbox = BoundingBox(1, 10, 2, 20) assert bbox.ixmin == 1 assert bbox.ixmax == 10 assert bbox.iymin == 2 assert bbox.iymax == 20 def test_bounding_box_init_minmax(): match = 'ixmin must be <= ixmax' with pytest.raises(ValueError, match=match): BoundingBox(100, 1, 1, 100) match = 'iymin must be <= iymax' with pytest.raises(ValueError, match=match): BoundingBox(1, 100, 100, 1) def test_bounding_box_inputs(): match = 'ixmin, ixmax, iymin, and iymax must all be integers' with pytest.raises(TypeError, match=match): BoundingBox([1], [10], [2], [9]) with pytest.raises(TypeError, match=match): BoundingBox([1, 2], 10, 2, 9) with pytest.raises(TypeError, match=match): BoundingBox(1.0, 10.0, 2.0, 9.0) with pytest.raises(TypeError, match=match): BoundingBox(1.3, 10, 2, 9) with pytest.raises(TypeError, match=match): BoundingBox(1, 10.3, 2, 9) with pytest.raises(TypeError, match=match): BoundingBox(1, 10, 2.3, 9) with pytest.raises(TypeError, match=match): BoundingBox(1, 10, 2, 9.3) def test_bounding_box_from_float(): # This is the example from the method docstring bbox = BoundingBox.from_float(xmin=1.0, xmax=10.0, ymin=2.0, ymax=20.0) assert bbox == BoundingBox(ixmin=1, ixmax=11, iymin=2, iymax=21) bbox = BoundingBox.from_float(xmin=1.4, xmax=10.4, ymin=1.6, ymax=10.6) assert bbox == BoundingBox(ixmin=1, ixmax=11, iymin=2, iymax=12) def test_bounding_box_eq(): bbox = BoundingBox(1, 10, 2, 20) assert bbox == BoundingBox(1, 10, 2, 20) assert bbox != BoundingBox(9, 10, 2, 20) assert bbox != BoundingBox(1, 99, 2, 20) assert bbox != BoundingBox(1, 10, 9, 20) assert bbox != BoundingBox(1, 10, 2, 99) match = 'Can compare BoundingBox only to another BoundingBox' with pytest.raises(TypeError, match=match): assert bbox == (1, 10, 2, 20) def test_bounding_box_repr(): bbox = BoundingBox(1, 10, 2, 20) assert repr(bbox) == 'BoundingBox(ixmin=1, ixmax=10, iymin=2, iymax=20)' def test_bounding_box_shape(): bbox = BoundingBox(1, 10, 2, 20) assert bbox.shape == (18, 9) def test_bounding_box_center(): bbox = BoundingBox(1, 10, 2, 20) assert bbox.center == (10.5, 5) def test_bounding_box_get_overlap_slices(): bbox = BoundingBox(1, 10, 2, 20) slc = ((slice(2, 20, None), slice(1, 10, None)), (slice(0, 18, None), slice(0, 9, None))) assert bbox.get_overlap_slices((50, 50)) == slc bbox = BoundingBox(-10, -1, 2, 20) assert bbox.get_overlap_slices((50, 50)) == (None, None) bbox = BoundingBox(-10, 10, -10, 20) slc = ((slice(0, 20, None), slice(0, 10, None)), (slice(10, 30, None), slice(10, 20, None))) assert bbox.get_overlap_slices((50, 50)) == slc def test_bounding_box_extent(): bbox = BoundingBox(1, 10, 2, 20) assert_allclose(bbox.extent, (0.5, 9.5, 1.5, 19.5)) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_bounding_box_as_artist(): bbox = BoundingBox(1, 10, 2, 20) patch = bbox.as_artist() assert_allclose(patch.get_xy(), (0.5, 1.5)) assert_allclose(patch.get_width(), 9) assert_allclose(patch.get_height(), 18) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_bounding_box_plot(): from matplotlib.patches import Rectangle bbox = BoundingBox(1, 10, 2, 20) patch = bbox.plot() assert isinstance(patch, Rectangle) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_bounding_box_to_aperture(): bbox = BoundingBox(1, 10, 2, 20) aper = RectangularAperture((5.0, 10.5), w=9.0, h=18.0, theta=0.0) bbox_aper = bbox.to_aperture() assert_allclose(bbox_aper.positions, aper.positions) assert bbox_aper.w == aper.w assert bbox_aper.h == aper.h assert bbox_aper.theta == aper.theta def test_bounding_box_union(): bbox1 = BoundingBox(1, 10, 2, 20) bbox2 = BoundingBox(5, 21, 7, 32) bbox_union_expected = BoundingBox(1, 21, 2, 32) bbox_union1 = bbox1 | bbox2 bbox_union2 = bbox1.union(bbox2) assert bbox_union1 == bbox_union_expected assert bbox_union1 == bbox_union2 match = 'BoundingBox can be joined only with another BoundingBox' with pytest.raises(TypeError, match=match): bbox1.union((5, 21, 7, 32)) def test_bounding_box_intersect(): bbox1 = BoundingBox(1, 10, 2, 20) bbox2 = BoundingBox(5, 21, 7, 32) bbox_intersect_expected = BoundingBox(5, 10, 7, 20) bbox_intersect1 = bbox1 & bbox2 bbox_intersect2 = bbox1.intersection(bbox2) assert bbox_intersect1 == bbox_intersect_expected assert bbox_intersect1 == bbox_intersect2 match = 'BoundingBox can be intersected only with another BoundingBox' with pytest.raises(TypeError, match=match): bbox1.intersection((5, 21, 7, 32)) assert bbox1.intersection(BoundingBox(30, 40, 50, 60)) is None ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/tests/test_circle.py0000644000175100001660000001625514755160622023070 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the circle module. """ import astropy.units as u import numpy as np import pytest from astropy.coordinates import SkyCoord from numpy.testing import assert_allclose from photutils.aperture.circle import (CircularAnnulus, CircularAperture, SkyCircularAnnulus, SkyCircularAperture) from photutils.aperture.tests.test_aperture_common import BaseTestAperture from photutils.utils._optional_deps import HAS_MATPLOTLIB POSITIONS = [(10, 20), (30, 40), (50, 60), (70, 80)] RA, DEC = np.transpose(POSITIONS) SKYCOORD = SkyCoord(ra=RA, dec=DEC, unit='deg') UNIT = u.arcsec RADII = (0.0, -1.0, -np.inf) class TestCircularAperture(BaseTestAperture): aperture = CircularAperture(POSITIONS, r=3.0) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot(self): self.aperture.plot() @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_returns_patches(self): from matplotlib import pyplot as plt from matplotlib.patches import Patch my_patches = self.aperture.plot() assert isinstance(my_patches, list) for patch in my_patches: assert isinstance(patch, Patch) # test creating a legend with these patches plt.legend(my_patches, list(range(len(my_patches)))) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): match = "'r' must be a positive scalar" with pytest.raises(ValueError, match=match): CircularAperture(POSITIONS, radius) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.r = 2.0 assert aper != self.aperture class TestCircularAnnulus(BaseTestAperture): aperture = CircularAnnulus(POSITIONS, r_in=3.0, r_out=7.0) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot(self): self.aperture.plot() @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_returns_patches(self): from matplotlib import pyplot as plt from matplotlib.patches import Patch my_patches = self.aperture.plot() assert isinstance(my_patches, list) for p in my_patches: assert isinstance(p, Patch) # make sure I can create a legend with these patches labels = list(range(len(my_patches))) plt.legend(my_patches, labels) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): match = "'r_in' must be a positive scalar" with pytest.raises(ValueError, match=match): CircularAnnulus(POSITIONS, r_in=radius, r_out=7.0) match = 'r_out must be greater than r_in' with pytest.raises(ValueError, match=match): CircularAnnulus(POSITIONS, r_in=3.0, r_out=radius) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.r_in = 2.0 assert aper != self.aperture class TestSkyCircularAperture(BaseTestAperture): aperture = SkyCircularAperture(SKYCOORD, r=3.0 * UNIT) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): match = "'r' must be greater than zero" with pytest.raises(ValueError, match=match): SkyCircularAperture(SKYCOORD, r=radius * UNIT) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.r = 2.0 * UNIT assert aper != self.aperture class TestSkyCircularAnnulus(BaseTestAperture): aperture = SkyCircularAnnulus(SKYCOORD, r_in=3.0 * UNIT, r_out=7.0 * UNIT) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): match = "'r_in' must be greater than zero" with pytest.raises(ValueError, match=match): SkyCircularAnnulus(SKYCOORD, r_in=radius * UNIT, r_out=7.0 * UNIT) match = "'r_out' must be greater than zero" with pytest.raises(ValueError, match=match): SkyCircularAnnulus(SKYCOORD, r_in=3.0 * UNIT, r_out=radius * UNIT) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.r_in = 2.0 * UNIT assert aper != self.aperture def test_slicing(): xypos = [(10, 10), (20, 20), (30, 30)] aper1 = CircularAperture(xypos, r=3) aper2 = aper1[0:2] assert len(aper2) == 2 aper3 = aper1[0] assert aper3.isscalar match = "A scalar 'CircularAperture' object has no len" with pytest.raises(TypeError, match=match): len(aper3) match = "A scalar 'CircularAperture' object cannot be indexed" with pytest.raises(TypeError, match=match): _ = aper3[0] def test_area_overlap(): data = np.ones((11, 11)) xypos = [(0, 0), (5, 5), (50, 50)] aper = CircularAperture(xypos, r=3) areas = aper.area_overlap(data) assert_allclose(areas, [10.304636, np.pi * 9.0, np.nan]) data2 = np.ones((11, 11)) * u.Jy areas = aper.area_overlap(data2) assert not isinstance(areas[0], u.Quantity) assert_allclose(areas, [10.304636, np.pi * 9.0, np.nan]) aper2 = CircularAperture(xypos[1], r=3) area2 = aper2.area_overlap(data) assert_allclose(area2, np.pi * 9.0) area2 = aper2.area_overlap(data2) assert not isinstance(area2, u.Quantity) assert_allclose(area2, np.pi * 9.0) def test_area_overlap_mask(): data = np.ones((11, 11)) mask = np.zeros((11, 11), dtype=bool) mask[0, 0:2] = True mask[5, 5:7] = True xypos = [(0, 0), (5, 5), (50, 50)] aper = CircularAperture(xypos, r=3) areas = aper.area_overlap(data, mask=mask) areas_exp = np.array([10.304636, np.pi * 9.0, np.nan]) - 2.0 assert_allclose(areas, areas_exp) mask = np.zeros((3, 3), dtype=bool) match = 'mask and data must have the same shape' with pytest.raises(ValueError, match=match): aper.area_overlap(data, mask=mask) def test_invalid_positions(): match = r"'positions' must be a \(x, y\) pixel position or a list" with pytest.raises(ValueError, match=match): _ = CircularAperture([], r=3) with pytest.raises(ValueError, match=match): _ = CircularAperture([1], r=3) with pytest.raises(ValueError, match=match): _ = CircularAperture([[1]], r=3) with pytest.raises(ValueError, match=match): _ = CircularAperture([1, 2, 3], r=3) with pytest.raises(ValueError, match=match): _ = CircularAperture([[1, 2, 3]], r=3) x = np.arange(3) y = np.arange(3) xypos = np.transpose((x, y)) * u.pix match = "'positions' must not be a Quantity" with pytest.raises(TypeError, match=match): _ = CircularAperture(xypos, r=3) x = np.arange(3) * u.pix y = np.arange(3) xypos = zip(x, y, strict=True) with pytest.raises(TypeError, match=match): _ = CircularAperture(xypos, r=3) x = np.arange(3) * u.pix y = np.arange(3) * u.pix xypos = zip(x, y, strict=True) with pytest.raises(TypeError, match=match): _ = CircularAperture(xypos, r=3) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/tests/test_converters.py0000644000175100001660000005021214755160622024010 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for converting regions.Region to Aperture. """ import numpy as np import pytest from astropy import units as u from astropy.coordinates import Angle, SkyCoord from astropy.tests.helper import assert_quantity_allclose from astropy.wcs import WCS from numpy.testing import assert_allclose from photutils.aperture import (CircularAnnulus, CircularAperture, EllipticalAnnulus, EllipticalAperture, RectangularAnnulus, RectangularAperture, SkyCircularAnnulus, SkyCircularAperture, SkyEllipticalAnnulus, SkyEllipticalAperture, SkyRectangularAnnulus, SkyRectangularAperture) from photutils.aperture.converters import (_scalar_aperture_to_region, _shapely_polygon_to_region, aperture_to_region, region_to_aperture) from photutils.utils._optional_deps import HAS_REGIONS, HAS_SHAPELY @pytest.fixture def image_2d_wcs(): return WCS( { 'CTYPE1': 'RA---TAN', 'CUNIT1': 'deg', 'CDELT1': -0.0002777777778, 'CRPIX1': 1, 'CRVAL1': 337.5202808, 'CTYPE2': 'DEC--TAN', 'CUNIT2': 'deg', 'CDELT2': 0.0002777777778, 'CRPIX2': 1, 'CRVAL2': -20.833333059999998, } ) def compare_region_shapes(reg1, reg2): from regions import PixCoord assert reg1.__class__ == reg2.__class__ for param in reg1._params: par1 = getattr(reg1, param) par2 = getattr(reg2, param) if isinstance(par1, PixCoord): assert_allclose(par1.xy, par2.xy) elif isinstance(par1, SkyCoord): assert par1 == par2 elif isinstance(par1, u.Quantity): assert_quantity_allclose(par1, par2) else: assert_allclose(par1, par2) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_translation_circle(image_2d_wcs): from regions import CirclePixelRegion, PixCoord region_shape = CirclePixelRegion(center=PixCoord(x=42, y=43), radius=4.2) aperture = region_to_aperture(region_shape) assert isinstance(aperture, CircularAperture) assert_allclose(aperture.positions, region_shape.center.xy) assert_allclose(aperture.r, region_shape.radius) region_sky = region_shape.to_sky(image_2d_wcs) aperture_sky = region_to_aperture(region_sky) assert isinstance(aperture_sky, SkyCircularAperture) assert aperture_sky.positions == region_sky.center # SkyCoord assert_quantity_allclose(aperture_sky.r, region_sky.radius) # NOTE: If these no longer fail, we also have to account for # non-scalar inputs. Assume this is representative for the sky # counterpart too. match = 'must be a scalar PixCoord' with pytest.raises(ValueError, match=match): CirclePixelRegion(center=PixCoord(x=[0, 42], y=[1, 43]), radius=4.2) match = 'must be a strictly positive scalar' with pytest.raises(ValueError, match=match): CirclePixelRegion(center=PixCoord(x=42, y=43), radius=[1, 4.2]) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_translation_ellipse(image_2d_wcs): from regions import EllipsePixelRegion, PixCoord region_shape = EllipsePixelRegion( center=PixCoord(x=42, y=43), width=16, height=10, angle=Angle(30, 'deg') ) aperture = region_to_aperture(region_shape) assert isinstance(aperture, EllipticalAperture) assert_allclose(aperture.positions, region_shape.center.xy) assert_allclose(aperture.a * 2, region_shape.width) assert_allclose(aperture.b * 2, region_shape.height) assert_quantity_allclose(aperture.theta, region_shape.angle) region_sky = region_shape.to_sky(image_2d_wcs) aperture_sky = region_to_aperture(region_sky) assert isinstance(aperture_sky, SkyEllipticalAperture) assert aperture_sky.positions == region_sky.center # SkyCoord assert_quantity_allclose(aperture_sky.a * 2, region_sky.width) assert_quantity_allclose(aperture_sky.b * 2, region_sky.height) assert_quantity_allclose(aperture_sky.theta + (90 * u.deg), region_sky.angle) # NOTE: If these no longer fail, we also have to account for # non-scalar inputs. Assume this is representative for the sky # counterpart too. match = 'must be a scalar PixCoord' with pytest.raises(ValueError, match=match): EllipsePixelRegion( center=PixCoord(x=[0, 42], y=[1, 43]), width=16, height=10, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): EllipsePixelRegion( center=PixCoord(x=42, y=43), width=[1, 16], height=10, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): EllipsePixelRegion( center=PixCoord(x=42, y=43), width=16, height=[1, 10], angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): EllipsePixelRegion( center=PixCoord(x=42, y=43), width=16, height=10, angle=Angle([0, 30], 'deg'), ) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_translation_rectangle(image_2d_wcs): from regions import PixCoord, RectanglePixelRegion region_shape = RectanglePixelRegion( center=PixCoord(x=42, y=43), width=16, height=10, angle=Angle(30, 'deg') ) aperture = region_to_aperture(region_shape) assert isinstance(aperture, RectangularAperture) assert_allclose(aperture.positions, region_shape.center.xy) assert_allclose(aperture.w, region_shape.width) assert_allclose(aperture.h, region_shape.height) assert_quantity_allclose(aperture.theta, region_shape.angle) region_sky = region_shape.to_sky(image_2d_wcs) aperture_sky = region_to_aperture(region_sky) assert isinstance(aperture_sky, SkyRectangularAperture) assert aperture_sky.positions == region_sky.center # SkyCoord assert_quantity_allclose(aperture_sky.w, region_sky.width) assert_quantity_allclose(aperture_sky.h, region_sky.height) assert_quantity_allclose(aperture_sky.theta + (90 * u.deg), region_sky.angle) # NOTE: If these no longer fail, we also have to account for # non-scalar inputs. Assume this is representative for the sky # counterpart too. match = 'must be a scalar PixCoord' with pytest.raises(ValueError, match=match): RectanglePixelRegion( center=PixCoord(x=[0, 42], y=[1, 43]), width=16, height=10, angle=Angle(30, 'deg'), ) match = 'must be a strictly positive scalar' with pytest.raises(ValueError, match=match): RectanglePixelRegion( center=PixCoord(x=42, y=43), width=[1, 16], height=10, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=match): RectanglePixelRegion( center=PixCoord(x=42, y=43), width=16, height=[1, 10], angle=Angle(30, 'deg'), ) match = 'must be a scalar' with pytest.raises(ValueError, match=match): RectanglePixelRegion( center=PixCoord(x=42, y=43), width=16, height=10, angle=Angle([0, 30], 'deg'), ) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_translation_circle_annulus(image_2d_wcs): from regions import CircleAnnulusPixelRegion, PixCoord region_shape = CircleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_radius=5, outer_radius=8 ) aperture = region_to_aperture(region_shape) assert isinstance(aperture, CircularAnnulus) assert_allclose(aperture.positions, region_shape.center.xy) assert_allclose(aperture.r_in, region_shape.inner_radius) assert_allclose(aperture.r_out, region_shape.outer_radius) region_sky = region_shape.to_sky(image_2d_wcs) aperture_sky = region_to_aperture(region_sky) assert isinstance(aperture_sky, SkyCircularAnnulus) assert aperture_sky.positions == region_sky.center # SkyCoord assert_quantity_allclose(aperture_sky.r_in, region_sky.inner_radius) assert_quantity_allclose(aperture_sky.r_out, region_sky.outer_radius) # NOTE: If these no longer fail, we also have to account for # non-scalar inputs. Assume this is representative for the sky # counterpart too. match = 'must be a scalar PixCoord' with pytest.raises(ValueError, match=match): CircleAnnulusPixelRegion( center=PixCoord(x=[0, 42], y=[1, 43]), inner_radius=5, outer_radius=8 ) with pytest.raises(ValueError, match=r'must be .* scalar'): CircleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_radius=[1, 5], outer_radius=8 ) with pytest.raises(ValueError, match=r'must be .* scalar'): CircleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_radius=5, outer_radius=[8, 10] ) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_translation_ellipse_annulus(image_2d_wcs): from regions import EllipseAnnulusPixelRegion, PixCoord region_shape = EllipseAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=3.5, outer_width=8.5, outer_height=6.5, angle=Angle(30, 'deg'), ) aperture = region_to_aperture(region_shape) assert isinstance(aperture, EllipticalAnnulus) assert_allclose(aperture.positions, region_shape.center.xy) assert_allclose(aperture.a_in * 2, region_shape.inner_width) assert_allclose(aperture.a_out * 2, region_shape.outer_width) assert_allclose(aperture.b_in * 2, region_shape.inner_height) assert_allclose(aperture.b_out * 2, region_shape.outer_height) assert_quantity_allclose(aperture.theta, region_shape.angle) region_sky = region_shape.to_sky(image_2d_wcs) aperture_sky = region_to_aperture(region_sky) assert isinstance(aperture_sky, SkyEllipticalAnnulus) assert aperture_sky.positions == region_sky.center # SkyCoord assert_quantity_allclose(aperture_sky.a_in * 2, region_sky.inner_width) assert_quantity_allclose(aperture_sky.a_out * 2, region_sky.outer_width) assert_quantity_allclose(aperture_sky.b_in * 2, region_sky.inner_height) assert_quantity_allclose(aperture_sky.b_out * 2, region_sky.outer_height) assert_quantity_allclose(aperture_sky.theta + (90 * u.deg), region_sky.angle) # NOTE: If these no longer fail, we also have to account for # non-scalar inputs. Assume this is representative for the sky # counterpart too. match = 'must be a scalar PixCoord' with pytest.raises(ValueError, match=match): EllipseAnnulusPixelRegion( center=PixCoord(x=[0, 42], y=[1, 43]), inner_width=5.5, inner_height=3.5, outer_width=8.5, outer_height=6.5, angle=Angle(30, 'deg'), ) match = 'must be a strictly positive scalar' with pytest.raises(ValueError, match=match): EllipseAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=[1, 5.5], inner_height=3.5, outer_width=8.5, outer_height=6.5, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=match): EllipseAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=[1, 3.5], outer_width=8.5, outer_height=6.5, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): EllipseAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=3.5, outer_width=[8.5, 10], outer_height=6.5, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): EllipseAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=3.5, outer_width=8.5, outer_height=[6.5, 10], angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): EllipseAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=3.5, outer_width=8.5, outer_height=6.5, angle=Angle([0, 30], 'deg'), ) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_translation_rectangle_annulus(image_2d_wcs): from regions import PixCoord, RectangleAnnulusPixelRegion region_shape = RectangleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=3.5, outer_width=8.5, outer_height=6.5, angle=Angle(30, 'deg'), ) aperture = region_to_aperture(region_shape) assert isinstance(aperture, RectangularAnnulus) assert_allclose(aperture.positions, region_shape.center.xy) assert_allclose(aperture.w_in, region_shape.inner_width) assert_allclose(aperture.w_out, region_shape.outer_width) assert_allclose(aperture.h_in, region_shape.inner_height) assert_allclose(aperture.h_out, region_shape.outer_height) assert_quantity_allclose(aperture.theta, region_shape.angle) region_sky = region_shape.to_sky(image_2d_wcs) aperture_sky = region_to_aperture(region_sky) assert isinstance(aperture_sky, SkyRectangularAnnulus) assert aperture_sky.positions == region_sky.center # SkyCoord assert_quantity_allclose(aperture_sky.w_in, region_sky.inner_width) assert_quantity_allclose(aperture_sky.w_out, region_sky.outer_width) assert_quantity_allclose(aperture_sky.h_in, region_sky.inner_height) assert_quantity_allclose(aperture_sky.h_out, region_sky.outer_height) assert_quantity_allclose(aperture_sky.theta + (90 * u.deg), region_sky.angle) # NOTE: If these no longer fail, we also have to account for # non-scalar inputs. Assume this is representative for the sky # counterpart too. match = 'must be a scalar PixCoord' with pytest.raises(ValueError, match=match): RectangleAnnulusPixelRegion( center=PixCoord(x=[0, 42], y=[1, 43]), inner_width=5.5, inner_height=3.5, outer_width=8.5, outer_height=6.5, angle=Angle(30, 'deg'), ) match = 'must be a strictly positive scalar' with pytest.raises(ValueError, match=match): RectangleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=[1, 5.5], inner_height=3.5, outer_width=8.5, outer_height=6.5, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=match): RectangleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=[1, 3.5], outer_width=8.5, outer_height=6.5, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): RectangleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=3.5, outer_width=[8.5, 10], outer_height=6.5, angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): RectangleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=3.5, outer_width=8.5, outer_height=[6.5, 10], angle=Angle(30, 'deg'), ) with pytest.raises(ValueError, match=r'must be .* scalar'): RectangleAnnulusPixelRegion( center=PixCoord(x=42, y=43), inner_width=5.5, inner_height=3.5, outer_width=8.5, outer_height=6.5, angle=Angle([0, 30], 'deg'), ) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_translation_polygon(): from regions import PixCoord, PolygonPixelRegion region_shape = PolygonPixelRegion(vertices=PixCoord(x=[1, 2, 2], y=[1, 1, 2])) match = r'Cannot convert .* to an Aperture object' with pytest.raises(TypeError, match=match): region_to_aperture(region_shape) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_aperture_to_region(): from regions import Region, Regions xypos = [(10, 20), (30, 40), (50, 60), (70, 80)] ra, dec = np.transpose(xypos) skycoord = SkyCoord(ra=ra, dec=dec, unit='deg') unit = u.arcsec apertures = [CircularAperture(xypos, r=3.0), CircularAnnulus(xypos, r_in=3.0, r_out=7.0), SkyCircularAperture(skycoord, r=3.0 * unit), SkyCircularAnnulus(skycoord, r_in=3.0 * unit, r_out=7.0 * unit), EllipticalAperture(xypos, a=10.0, b=5.0, theta=np.pi / 2.0), EllipticalAnnulus(xypos, a_in=10.0, a_out=20.0, b_out=17.0, theta=np.pi / 3), SkyEllipticalAperture(skycoord, a=10.0 * unit, b=5.0 * unit, theta=30 * u.deg), SkyEllipticalAnnulus(skycoord, a_in=10.0 * unit, a_out=20.0 * unit, b_out=17.0 * unit, theta=60 * u.deg), RectangularAperture(xypos, w=10.0, h=5.0, theta=np.pi / 2.0), RectangularAnnulus(xypos, w_in=10.0, w_out=20.0, h_out=17, theta=np.pi / 3), SkyRectangularAperture(skycoord, w=10.0 * unit, h=5.0 * unit, theta=30 * u.deg), SkyRectangularAnnulus(skycoord, w_in=10.0 * unit, w_out=20.0 * unit, h_out=17.0 * unit, theta=60 * u.deg)] for aperture in apertures: region0 = aperture_to_region(aperture[0]) region = aperture_to_region(aperture) assert isinstance(region0, Region) assert isinstance(region, Regions) assert len(region) == len(aperture) aper0 = region_to_aperture(region0) assert aper0 == aperture[0] @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_invalid_inputs(): from regions import CirclePixelRegion, PixCoord aperture = CircularAperture((10, 12), r=4.2) region = CirclePixelRegion(center=PixCoord(x=10, y=12), radius=4.2) match = 'Input region must be a Region object' with pytest.raises(TypeError, match=match): region_to_aperture(aperture) match = 'Input aperture must be an Aperture object' with pytest.raises(TypeError, match=match): aperture_to_region(region) aperture = CircularAperture(((10, 12), (21, 7)), r=4.2) match = r'Only scalar .* apertures are supported' with pytest.raises(ValueError, match=match): _scalar_aperture_to_region(aperture) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') def test_shapely_polygon_to_region(): from regions import PixCoord, PolygonPixelRegion from shapely.geometry 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 polygon must be a shapely Polygon object' with pytest.raises(TypeError, match=match): _shapely_polygon_to_region('foo') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/tests/test_ellipse.py0000644000175100001660000001507214755160622023260 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the ellipse module. """ import astropy.units as u import numpy as np import pytest from astropy.coordinates import Angle, SkyCoord from astropy.tests.helper import assert_quantity_allclose from photutils.aperture.ellipse import (EllipticalAnnulus, EllipticalAperture, SkyEllipticalAnnulus, SkyEllipticalAperture) from photutils.aperture.tests.test_aperture_common import BaseTestAperture POSITIONS = [(10, 20), (30, 40), (50, 60), (70, 80)] RA, DEC = np.transpose(POSITIONS) SKYCOORD = SkyCoord(ra=RA, dec=DEC, unit='deg') UNIT = u.arcsec RADII = (0.0, -1.0, -np.inf) class TestEllipticalAperture(BaseTestAperture): aperture = EllipticalAperture(POSITIONS, a=10.0, b=5.0, theta=np.pi / 2.0) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): match = "'a' must be a positive scalar" with pytest.raises(ValueError, match=match): EllipticalAperture(POSITIONS, a=radius, b=5.0, theta=np.pi / 2.0) match = "'b' must be a positive scalar" with pytest.raises(ValueError, match=match): EllipticalAperture(POSITIONS, a=10.0, b=radius, theta=np.pi / 2.0) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.a = 20.0 assert aper != self.aperture def test_theta(self): assert isinstance(self.aperture.theta, u.Quantity) assert self.aperture.theta.unit == u.rad class TestEllipticalAnnulus(BaseTestAperture): aperture = EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=20.0, b_out=17.0, theta=np.pi / 3) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): match = "'a_in' must be a positive scalar" with pytest.raises(ValueError, match=match): EllipticalAnnulus(POSITIONS, a_in=radius, a_out=20.0, b_out=17.0, theta=np.pi / 3) match = '"a_out" must be greater than "a_in"' with pytest.raises(ValueError, match=match): EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=radius, b_out=17.0, theta=np.pi / 3) match = "'b_out' must be a positive scalar" with pytest.raises(ValueError, match=match): EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=20.0, b_out=radius, theta=np.pi / 3) match = "'b_in' must be a positive scalar" with pytest.raises(ValueError, match=match): EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=20.0, b_out=17.0, b_in=radius, theta=np.pi / 3) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.a_in = 2.0 assert aper != self.aperture def test_theta(self): assert isinstance(self.aperture.theta, u.Quantity) assert self.aperture.theta.unit == u.rad class TestSkyEllipticalAperture(BaseTestAperture): aperture = SkyEllipticalAperture(SKYCOORD, a=10.0 * UNIT, b=5.0 * UNIT, theta=30 * u.deg) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): match = "'a' must be greater than zero" with pytest.raises(ValueError, match=match): SkyEllipticalAperture(SKYCOORD, a=radius * UNIT, b=5.0 * UNIT, theta=30 * u.deg) match = "'b' must be greater than zero" with pytest.raises(ValueError, match=match): SkyEllipticalAperture(SKYCOORD, a=10.0 * UNIT, b=radius * UNIT, theta=30 * u.deg) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.a = 2.0 * UNIT assert aper != self.aperture class TestSkyEllipticalAnnulus(BaseTestAperture): aperture = SkyEllipticalAnnulus(SKYCOORD, a_in=10.0 * UNIT, a_out=20.0 * UNIT, b_out=17.0 * UNIT, theta=60 * u.deg) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): match = "'a_in' must be greater than zero" with pytest.raises(ValueError, match=match): SkyEllipticalAnnulus(SKYCOORD, a_in=radius * UNIT, a_out=20.0 * UNIT, b_out=17.0 * UNIT, theta=60 * u.deg) match = '"a_out" must be greater than "a_in"' with pytest.raises(ValueError, match=match): SkyEllipticalAnnulus(SKYCOORD, a_in=10.0 * UNIT, a_out=radius * UNIT, b_out=17.0 * UNIT, theta=60 * u.deg) match = "'b_out' must be greater than zero" with pytest.raises(ValueError, match=match): SkyEllipticalAnnulus(SKYCOORD, a_in=10.0 * UNIT, a_out=20.0 * UNIT, b_out=radius * UNIT, theta=60 * u.deg) match = "'b_in' must be greater than zero" with pytest.raises(ValueError, match=match): SkyEllipticalAnnulus(SKYCOORD, a_in=10.0 * UNIT, a_out=20.0 * UNIT, b_out=17.0 * UNIT, b_in=radius * UNIT, theta=60 * u.deg) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.a_in = 2.0 * UNIT assert aper != self.aperture def test_ellipse_theta_quantity(): aper1 = EllipticalAperture(POSITIONS, a=10.0, b=5.0, theta=np.pi / 2.0) theta = u.Quantity(90 * u.deg) aper2 = EllipticalAperture(POSITIONS, a=10.0, b=5.0, theta=theta) theta = Angle(90 * u.deg) aper3 = EllipticalAperture(POSITIONS, a=10.0, b=5.0, theta=theta) assert_quantity_allclose(aper1.theta, aper2.theta) assert_quantity_allclose(aper1.theta, aper3.theta) def test_ellipse_annulus_theta_quantity(): aper1 = EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=20.0, b_out=17.0, theta=np.pi / 3) theta = u.Quantity(60 * u.deg) aper2 = EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=20.0, b_out=17.0, theta=theta) theta = Angle(60 * u.deg) aper3 = EllipticalAnnulus(POSITIONS, a_in=10.0, a_out=20.0, b_out=17.0, theta=theta) assert_quantity_allclose(aper1.theta, aper2.theta) assert_quantity_allclose(aper1.theta, aper3.theta) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/tests/test_mask.py0000644000175100001660000001662414755160622022562 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the mask module. """ import astropy.units as u import numpy as np import pytest from astropy.utils import minversion from numpy.testing import assert_allclose, assert_almost_equal from photutils.aperture.bounding_box import BoundingBox from photutils.aperture.circle import CircularAnnulus, CircularAperture from photutils.aperture.mask import ApertureMask from photutils.aperture.rectangle import RectangularAnnulus NUMPY_LT_2_0 = not minversion(np, '2.0') COPY_IF_NEEDED = False if NUMPY_LT_2_0 else None POSITIONS = [(-20, -20), (-20, 20), (20, -20), (60, 60)] def test_mask_input_shapes(): mask_data = np.ones((10, 10)) bbox = BoundingBox(5, 10, 5, 10) match = 'mask data and bounding box must have the same shape' with pytest.raises(ValueError, match=match): ApertureMask(mask_data, bbox) def test_mask_array(): mask_data = np.ones((10, 10)) bbox = BoundingBox(5, 15, 5, 15) mask = ApertureMask(mask_data, bbox) data = np.array(mask) assert_allclose(data, mask.data) def test_mask_copy(): bbox = BoundingBox(5, 15, 5, 15) mask = ApertureMask(np.ones((10, 10)), bbox) mask_copy = np.array(mask, copy=True) mask_copy[0, 0] = 100.0 assert mask.data[0, 0] == 1.0 mask = ApertureMask(np.ones((10, 10)), bbox) mask_copy = np.array(mask, copy=False) mask_copy[0, 0] = 100.0 assert mask.data[0, 0] == 100.0 # no copy; copy=None returns a copy only if __array__ returns a copy # copy=None was introduced in NumPy 2.0 mask = ApertureMask(np.ones((10, 10)), bbox) mask_copy = np.array(mask, copy=COPY_IF_NEEDED) mask_copy[0, 0] = 100.0 assert mask.data[0, 0] == 100.0 # no copy mask = ApertureMask(np.ones((10, 10)), bbox) mask_copy = np.asarray(mask) mask_copy[0, 0] = 100.0 assert mask.data[0, 0] == 100.0 # needs to copy because of the dtype change mask = ApertureMask(np.ones((10, 10)), bbox) mask_copy = np.asarray(mask, dtype=int) mask_copy[0, 0] = 100.0 assert mask.data[0, 0] == 1.0 def test_mask_get_overlap_slices(): aper = CircularAperture((5, 5), r=10.0) mask = aper.to_mask() slc = ((slice(0, 16, None), slice(0, 16, None)), (slice(5, 21, None), slice(5, 21, None))) assert mask.get_overlap_slices((25, 25)) == slc def test_mask_cutout_shape(): mask_data = np.ones((10, 10)) bbox = BoundingBox(5, 15, 5, 15) mask = ApertureMask(mask_data, bbox) match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): mask.cutout(np.arange(10)) match = 'input shape must have 2 elements' with pytest.raises(ValueError, match=match): mask.to_image((10,)) def test_mask_cutout_copy(): data = np.ones((50, 50)) aper = CircularAperture((25, 25), r=10.0) mask = aper.to_mask() cutout = mask.cutout(data, copy=True) data[25, 25] = 100.0 assert cutout[10, 10] == 1.0 # test quantity data data2 = np.ones((50, 50)) * u.adu cutout2 = mask.cutout(data2, copy=True) assert cutout2.unit == data2.unit data2[25, 25] = 100.0 * u.adu assert cutout2[10, 10].value == 1.0 @pytest.mark.parametrize('position', POSITIONS) def test_mask_cutout_no_overlap(position): data = np.ones((50, 50)) aper = CircularAperture(position, r=10.0) mask = aper.to_mask() cutout = mask.cutout(data) assert cutout is None weighted_data = mask.multiply(data) assert weighted_data is None image = mask.to_image(data.shape) assert image is None @pytest.mark.parametrize('position', POSITIONS) def test_mask_cutout_partial_overlap(position): data = np.ones((50, 50)) aper = CircularAperture(position, r=30.0) mask = aper.to_mask() cutout = mask.cutout(data) assert cutout.shape == mask.shape weighted_data = mask.multiply(data) assert weighted_data.shape == mask.shape image = mask.to_image(data.shape) assert image.shape == data.shape def test_mask_multiply(): radius = 10.0 data = np.ones((50, 50)) aper = CircularAperture((25, 25), r=radius) mask = aper.to_mask() data_weighted = mask.multiply(data) assert_almost_equal(np.sum(data_weighted), np.pi * radius**2) # test that multiply() returns a copy data[25, 25] = 100.0 assert data_weighted[10, 10] == 1.0 def test_mask_multiply_quantity(): radius = 10.0 data = np.ones((50, 50)) * u.adu aper = CircularAperture((25, 25), r=radius) mask = aper.to_mask() data_weighted = mask.multiply(data) assert data_weighted.unit == u.adu assert_almost_equal(np.sum(data_weighted.value), np.pi * radius**2) # test that multiply() returns a copy data[25, 25] = 100.0 * u.adu assert data_weighted[10, 10].value == 1.0 @pytest.mark.parametrize('value', [np.nan, np.inf]) def test_mask_nonfinite_fill_value(value): aper = CircularAnnulus((0, 0), 10, 20) data = np.ones((101, 101)).astype(int) cutout = aper.to_mask().cutout(data, fill_value=value) assert ~np.isfinite(cutout[0, 0]) def test_mask_multiply_fill_value(): aper = CircularAnnulus((0, 0), 10, 20) data = np.ones((101, 101)).astype(int) cutout = aper.to_mask().multiply(data, fill_value=np.nan) xypos = ((20, 20), (5, 5), (5, 35), (35, 5), (35, 35)) for x, y in xypos: assert np.isnan(cutout[y, x]) def test_mask_nonfinite_in_bbox(): """ Regression test that non-finite data values outside of the mask but within the bounding box are set to zero. """ data = np.ones((101, 101)) data[33, 33] = np.nan data[67, 67] = np.inf data[33, 67] = -np.inf data[22, 22] = np.nan data[22, 23] = np.inf radius = 20.0 aper1 = CircularAperture((50, 50), r=radius) aper2 = CircularAperture((5, 5), r=radius) wdata1 = aper1.to_mask(method='exact').multiply(data) assert_allclose(np.sum(wdata1), np.pi * radius**2) wdata2 = aper2.to_mask(method='exact').multiply(data) assert_allclose(np.sum(wdata2), 561.6040111923013) def test_mask_get_values(): aper = CircularAnnulus(((0, 0), (50, 50), (100, 100)), 10, 20) data = np.ones((101, 101)) values = [mask.get_values(data) for mask in aper.to_mask()] shapes = [val.shape for val in values] sums = [np.sum(val) for val in values] assert shapes[0] == (278,) assert shapes[1] == (1068,) assert shapes[2] == (278,) sums_expected = (245.621534, 942.477796, 245.621534) assert_allclose(sums, sums_expected) def test_mask_get_values_no_overlap(): aper = CircularAperture((-100, -100), r=3) data = np.ones((51, 51)) values = aper.to_mask().get_values(data) assert values.shape == (0,) def test_mask_get_values_mask(): aper = CircularAperture((24.5, 24.5), r=10.0) data = np.ones((51, 51)) mask = aper.to_mask() match = 'mask and data must have the same shape' with pytest.raises(ValueError, match=match): mask.get_values(data, mask=np.ones(3)) arr = mask.get_values(data, mask=None) assert_allclose(np.sum(arr), 100.0 * np.pi) data_mask = np.zeros(data.shape, dtype=bool) data_mask[25:] = True arr2 = mask.get_values(data, mask=data_mask) assert_allclose(np.sum(arr2), 100.0 * np.pi / 2.0) def test_rectangular_annulus_hin(): aper = RectangularAnnulus((25, 25), 2, 4, 20, h_in=18, theta=0) mask = aper.to_mask(method='center') assert mask.data.shape == (21, 5) assert np.count_nonzero(mask.data) == 40 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/tests/test_photometry.py0000644000175100001660000012114114755160622024030 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the photometry module. """ import astropy.units as u import numpy as np import pytest from astropy.coordinates import SkyCoord from astropy.io import fits from astropy.nddata import NDData, StdDevUncertainty from astropy.table import Table from astropy.utils.exceptions import AstropyUserWarning from astropy.wcs import WCS from numpy.testing import assert_allclose, assert_array_less, assert_equal from photutils.aperture.circle import (CircularAnnulus, CircularAperture, SkyCircularAnnulus, SkyCircularAperture) from photutils.aperture.ellipse import (EllipticalAnnulus, EllipticalAperture, SkyEllipticalAnnulus, SkyEllipticalAperture) from photutils.aperture.photometry import aperture_photometry from photutils.aperture.rectangle import (RectangularAnnulus, RectangularAperture, SkyRectangularAnnulus, SkyRectangularAperture) from photutils.datasets import (get_path, make_4gaussians_image, make_gwcs, make_wcs) from photutils.utils._optional_deps import (HAS_GWCS, HAS_MATPLOTLIB, HAS_REGIONS) APERTURE_CL = [CircularAperture, CircularAnnulus, EllipticalAperture, EllipticalAnnulus, RectangularAperture, RectangularAnnulus] TEST_APERTURES = list(zip(APERTURE_CL, ((3.0,), (3.0, 5.0), (3.0, 5.0, 1.0), (3.0, 5.0, 4.0, 12.0 / 5.0, 1.0), (5, 8, np.pi / 4), (8, 12, 8, 16.0 / 3.0, np.pi / 8)), strict=True)) @pytest.mark.parametrize(('aperture_class', 'params'), TEST_APERTURES) def test_outside_array(aperture_class, params): data = np.ones((10, 10), dtype=float) aperture = aperture_class((-60, 60), *params) fluxtable = aperture_photometry(data, aperture) # aperture is fully outside array: assert np.isnan(fluxtable['aperture_sum']) @pytest.mark.parametrize(('aperture_class', 'params'), TEST_APERTURES) def test_inside_array_simple(aperture_class, params): data = np.ones((40, 40), dtype=float) aperture = aperture_class((20.0, 20.0), *params) table1 = aperture_photometry(data, aperture, method='center', subpixels=10) table2 = aperture_photometry(data, aperture, method='subpixel', subpixels=10) table3 = aperture_photometry(data, aperture, method='exact', subpixels=10) true_flux = aperture.area assert table1['aperture_sum'] < table3['aperture_sum'] if not isinstance(aperture, (RectangularAperture, RectangularAnnulus)): assert_allclose(table3['aperture_sum'], true_flux) assert_allclose(table2['aperture_sum'], table3['aperture_sum'], atol=0.1) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') @pytest.mark.parametrize(('aperture_class', 'params'), TEST_APERTURES) def test_aperture_plots(aperture_class, params): # This test should run without any errors, and there is no return # value. # TODO: check the content of the plot aperture = aperture_class((20.0, 20.0), *params) aperture.plot() def test_aperture_pixel_positions(): pos1 = (10, 20) pos2 = [(10, 20)] r = 3 ap1 = CircularAperture(pos1, r) ap2 = CircularAperture(pos2, r) assert not np.array_equal(ap1.positions, ap2.positions) class BaseTestAperturePhotometry: def test_array_error(self): # Array error error = np.ones(self.data.shape, dtype=float) if not hasattr(self, 'mask'): mask = None true_error = np.sqrt(self.area) else: mask = self.mask # 1 masked pixel true_error = np.sqrt(self.area - 1) table1 = aperture_photometry(self.data, self.aperture, method='center', mask=mask, error=error) table2 = aperture_photometry(self.data, self.aperture, method='subpixel', subpixels=12, mask=mask, error=error) table3 = aperture_photometry(self.data, self.aperture, method='exact', mask=mask, error=error) if not isinstance(self.aperture, (RectangularAperture, RectangularAnnulus)): assert_allclose(table3['aperture_sum'], self.true_flux) assert_allclose(table2['aperture_sum'], table3['aperture_sum'], atol=0.1) assert np.all(table1['aperture_sum'] < table3['aperture_sum']) if not isinstance(self.aperture, (RectangularAperture, RectangularAnnulus)): assert_allclose(table3['aperture_sum_err'], true_error) assert_allclose(table2['aperture_sum_err'], table3['aperture_sum_err'], atol=0.1) assert np.all(table1['aperture_sum_err'] < table3['aperture_sum_err']) class TestCircular(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) position = (20.0, 20.0) r = 10.0 self.aperture = CircularAperture(position, r) self.area = np.pi * r * r self.true_flux = self.area class TestCircularArray(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) position = ((20.0, 20.0), (25.0, 25.0)) r = 10.0 self.aperture = CircularAperture(position, r) self.area = np.pi * r * r self.area = np.array((self.area,) * 2) self.true_flux = self.area class TestCircularAnnulus(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) position = (20.0, 20.0) r_in = 8.0 r_out = 10.0 self.aperture = CircularAnnulus(position, r_in, r_out) self.area = np.pi * (r_out * r_out - r_in * r_in) self.true_flux = self.area class TestCircularAnnulusArray(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) position = ((20.0, 20.0), (25.0, 25.0)) r_in = 8.0 r_out = 10.0 self.aperture = CircularAnnulus(position, r_in, r_out) self.area = np.pi * (r_out * r_out - r_in * r_in) self.area = np.array((self.area,) * 2) self.true_flux = self.area class TestElliptical(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) position = (20.0, 20.0) a = 10.0 b = 5.0 theta = -np.pi / 4.0 self.aperture = EllipticalAperture(position, a, b, theta=theta) self.area = np.pi * a * b self.true_flux = self.area class TestEllipticalAnnulus(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) position = (20.0, 20.0) a_in = 5.0 a_out = 8.0 b_out = 5.0 theta = -np.pi / 4.0 self.aperture = EllipticalAnnulus(position, a_in, a_out, b_out, theta=theta) self.area = (np.pi * (a_out * b_out) - np.pi * (a_in * b_out * a_in / a_out)) self.true_flux = self.area class TestRectangularAperture(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) position = (20.0, 20.0) h = 5.0 w = 8.0 theta = np.pi / 4.0 self.aperture = RectangularAperture(position, w, h, theta=theta) self.area = h * w self.true_flux = self.area class TestRectangularAnnulus(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) position = (20.0, 20.0) h_out = 8.0 w_in = 8.0 w_out = 12.0 h_in = w_in * h_out / w_out theta = np.pi / 8.0 self.aperture = RectangularAnnulus(position, w_in, w_out, h_out, theta=theta) self.area = h_out * w_out - h_in * w_in self.true_flux = self.area class TestMaskedSkipCircular(BaseTestAperturePhotometry): def setup_class(self): self.data = np.ones((40, 40), dtype=float) self.mask = np.zeros((40, 40), dtype=bool) self.mask[20, 20] = True position = (20.0, 20.0) r = 10.0 self.aperture = CircularAperture(position, r) self.area = np.pi * r * r self.true_flux = self.area - 1 class BaseTestDifferentData: def test_basic_circular_aperture_photometry(self): aperture = CircularAperture(self.position, self.radius) table = aperture_photometry(self.data, aperture, method='exact') assert_allclose(table['aperture_sum'].value, self.true_flux) assert table['aperture_sum'].unit, self.fluxunit assert np.all(table['xcenter'].value == np.transpose(self.position)[0]) assert np.all(table['ycenter'].value == np.transpose(self.position)[1]) class TestInputNDData(BaseTestDifferentData): def setup_class(self): data = np.ones((40, 40), dtype=float) self.data = NDData(data, unit=u.adu) self.radius = 3 self.position = [(20, 20), (30, 30)] self.true_flux = np.pi * self.radius * self.radius self.fluxunit = u.adu def test_input_wcs(): data = make_4gaussians_image() wcs = make_wcs(data.shape) # hard wired positions in make_4gaussian_image xypos = np.transpose(([160.0, 25.0, 150.0, 90.0], [70.0, 40.0, 25.0, 60.0])) aper = CircularAperture(xypos, 3.0) aper3 = [CircularAperture((160.0, 70.0), r) for r in (3, 4, 5)] phot1 = aperture_photometry(data, aper[0], wcs=wcs) phot2 = aperture_photometry(data, aper, wcs=wcs) phot3 = aperture_photometry(data, aper3, wcs=wcs) assert 'sky_center' in phot1.colnames assert 'sky_center' in phot2.colnames assert 'sky_center' in phot3.colnames @pytest.mark.remote_data def test_wcs_based_photometry_to_catalog(): pathcat = get_path('spitzer_example_catalog.xml', location='remote') pathhdu = get_path('spitzer_example_image.fits', location='remote') hdu = fits.open(pathhdu) data = u.Quantity(hdu[0].data, unit=hdu[0].header['BUNIT']) wcs = WCS(hdu[0].header) catalog = Table.read(pathcat) pos_skycoord = SkyCoord(catalog['l'], catalog['b'], frame='galactic') photometry_skycoord = aperture_photometry( data, SkyCircularAperture(pos_skycoord, 4 * u.arcsec), wcs=wcs) # Photometric unit conversion is needed to match the catalog factor = (1.2 * u.arcsec) ** 2 / u.pixel converted_aperture_sum = (photometry_skycoord['aperture_sum'] * factor).to(u.mJy / u.pixel) fluxes_catalog = catalog['f4_5'].filled() # There shouldn't be large outliers, but some differences is OK, as # fluxes_catalog is based on PSF photometry, etc. assert_allclose(fluxes_catalog, converted_aperture_sum.value, rtol=1e0) assert np.mean(np.fabs((fluxes_catalog - converted_aperture_sum.value) / fluxes_catalog)) < 0.1 # close the file hdu.close() def test_wcs_based_photometry(): data = make_4gaussians_image() wcs = make_wcs(data.shape) # hard wired positions in make_4gaussian_image pos_orig_pixel = u.Quantity(([160.0, 25.0, 150.0, 90.0], [70.0, 40.0, 25.0, 60.0]), unit=u.pixel) pos_skycoord = wcs.pixel_to_world(pos_orig_pixel[0], pos_orig_pixel[1]) pos_skycoord_s = pos_skycoord[2] photometry_skycoord_circ = aperture_photometry( data, SkyCircularAperture(pos_skycoord, 3 * u.arcsec), wcs=wcs) photometry_skycoord_circ_2 = aperture_photometry( data, SkyCircularAperture(pos_skycoord, 2 * u.arcsec), wcs=wcs) photometry_skycoord_circ_s = aperture_photometry( data, SkyCircularAperture(pos_skycoord_s, 3 * u.arcsec), wcs=wcs) assert_allclose(photometry_skycoord_circ['aperture_sum'][2], photometry_skycoord_circ_s['aperture_sum']) photometry_skycoord_circ_ann = aperture_photometry( data, SkyCircularAnnulus(pos_skycoord, 2 * u.arcsec, 3 * u.arcsec), wcs=wcs) photometry_skycoord_circ_ann_s = aperture_photometry( data, SkyCircularAnnulus(pos_skycoord_s, 2 * u.arcsec, 3 * u.arcsec), wcs=wcs) assert_allclose(photometry_skycoord_circ_ann['aperture_sum'][2], photometry_skycoord_circ_ann_s['aperture_sum']) assert_allclose(photometry_skycoord_circ_ann['aperture_sum'], photometry_skycoord_circ['aperture_sum'] - photometry_skycoord_circ_2['aperture_sum']) photometry_skycoord_ell = aperture_photometry( data, SkyEllipticalAperture(pos_skycoord, 3 * u.arcsec, 3.0001 * u.arcsec, theta=45 * u.arcsec), wcs=wcs) photometry_skycoord_ell_2 = aperture_photometry( data, SkyEllipticalAperture(pos_skycoord, 2 * u.arcsec, 2.0001 * u.arcsec, theta=45 * u.arcsec), wcs=wcs) photometry_skycoord_ell_s = aperture_photometry( data, SkyEllipticalAperture(pos_skycoord_s, 3 * u.arcsec, 3.0001 * u.arcsec, theta=45 * u.arcsec), wcs=wcs) photometry_skycoord_ell_ann = aperture_photometry( data, SkyEllipticalAnnulus(pos_skycoord, 2 * u.arcsec, 3 * u.arcsec, 3.0001 * u.arcsec, theta=45 * u.arcsec), wcs=wcs) photometry_skycoord_ell_ann_s = aperture_photometry( data, SkyEllipticalAnnulus(pos_skycoord_s, 2 * u.arcsec, 3 * u.arcsec, 3.0001 * u.arcsec, theta=45 * u.arcsec), wcs=wcs) assert_allclose(photometry_skycoord_ell['aperture_sum'][2], photometry_skycoord_ell_s['aperture_sum']) assert_allclose(photometry_skycoord_ell_ann['aperture_sum'][2], photometry_skycoord_ell_ann_s['aperture_sum']) assert_allclose(photometry_skycoord_ell['aperture_sum'], photometry_skycoord_circ['aperture_sum'], rtol=5e-3) assert_allclose(photometry_skycoord_ell_ann['aperture_sum'], photometry_skycoord_ell['aperture_sum'] - photometry_skycoord_ell_2['aperture_sum'], rtol=1e-4) photometry_skycoord_rec = aperture_photometry( data, SkyRectangularAperture(pos_skycoord, 6 * u.arcsec, 6 * u.arcsec, 0 * u.arcsec), method='subpixel', subpixels=20, wcs=wcs) photometry_skycoord_rec_4 = aperture_photometry( data, SkyRectangularAperture(pos_skycoord, 4 * u.arcsec, 4 * u.arcsec, 0 * u.arcsec), method='subpixel', subpixels=20, wcs=wcs) photometry_skycoord_rec_s = aperture_photometry( data, SkyRectangularAperture(pos_skycoord_s, 6 * u.arcsec, 6 * u.arcsec, 0 * u.arcsec), method='subpixel', subpixels=20, wcs=wcs) photometry_skycoord_rec_ann = aperture_photometry( data, SkyRectangularAnnulus(pos_skycoord, 4 * u.arcsec, 6 * u.arcsec, 6 * u.arcsec, theta=0 * u.arcsec), method='subpixel', subpixels=20, wcs=wcs) photometry_skycoord_rec_ann_s = aperture_photometry( data, SkyRectangularAnnulus(pos_skycoord_s, 4 * u.arcsec, 6 * u.arcsec, 6 * u.arcsec, theta=0 * u.arcsec), method='subpixel', subpixels=20, wcs=wcs) assert_allclose(photometry_skycoord_rec['aperture_sum'][2], photometry_skycoord_rec_s['aperture_sum']) assert np.all(photometry_skycoord_rec['aperture_sum'] > photometry_skycoord_circ['aperture_sum']) assert_allclose(photometry_skycoord_rec_ann['aperture_sum'][2], photometry_skycoord_rec_ann_s['aperture_sum']) assert_allclose(photometry_skycoord_rec_ann['aperture_sum'], photometry_skycoord_rec['aperture_sum'] - photometry_skycoord_rec_4['aperture_sum'], rtol=1e-4) def test_basic_circular_aperture_photometry_unit(): radius = 3 true_flux = np.pi * radius * radius aper = CircularAperture((12, 12), radius) data1 = np.ones((25, 25), dtype=float) table1 = aperture_photometry(data1, aper) assert_allclose(table1['aperture_sum'], true_flux) unit = u.adu data2 = u.Quantity(data1 * unit) table2 = aperture_photometry(data2, aper) assert_allclose(table2['aperture_sum'].value, true_flux) assert table2['aperture_sum'].unit == data2.unit == unit error1 = np.ones((25, 25)) match = 'then they both must have the same units' with pytest.raises(ValueError, match=match): # data has unit, but error does not aperture_photometry(data2, aper, error=error1) error2 = u.Quantity(error1 * u.Jy) with pytest.raises(ValueError, match=match): # data and error have different units aperture_photometry(data2, aper, error=error2) def test_aperture_photometry_with_error_units(): """ Test aperture_photometry when error has units (see #176). """ data1 = np.ones((40, 40), dtype=float) data2 = u.Quantity(data1, unit=u.adu) error = u.Quantity(data1, unit=u.adu) radius = 3 true_flux = np.pi * radius * radius unit = u.adu position = (20, 20) table1 = aperture_photometry(data2, CircularAperture(position, radius), error=error) assert_allclose(table1['aperture_sum'].value, true_flux) assert_allclose(table1['aperture_sum_err'].value, np.sqrt(true_flux)) assert table1['aperture_sum'].unit == unit assert table1['aperture_sum_err'].unit == unit def test_aperture_photometry_inputs_with_mask(): """ Test that aperture_photometry does not modify the input data or error array when a mask is input. """ data = np.ones((5, 5)) aperture = CircularAperture((2, 2), 2.0) mask = np.zeros_like(data, dtype=bool) data[2, 2] = 100.0 # bad pixel mask[2, 2] = True error = np.sqrt(data) data_in = data.copy() error_in = error.copy() t1 = aperture_photometry(data, aperture, error=error, mask=mask) assert_equal(data, data_in) assert_equal(error, error_in) assert_allclose(t1['aperture_sum'][0], 11.5663706144) t2 = aperture_photometry(data, aperture) assert_allclose(t2['aperture_sum'][0], 111.566370614) TEST_ELLIPSE_EXACT_APERTURES = [(3.469906, 3.923861394, 3.0), (0.3834415188257778, 0.3834415188257778, 0.3)] @pytest.mark.parametrize(('x', 'y', 'r'), TEST_ELLIPSE_EXACT_APERTURES) def test_ellipse_exact_grid(x, y, r): """ Test elliptical exact aperture photometry on a grid of pixel positions. This is a regression test for the bug discovered in this issue: https://github.com/astropy/photutils/issues/198 """ data = np.ones((10, 10)) aperture = EllipticalAperture((x, y), r, r, 0.0) t = aperture_photometry(data, aperture, method='exact') actual = t['aperture_sum'][0] / (np.pi * r**2) assert_allclose(actual, 1) @pytest.mark.parametrize('value', [np.nan, np.inf]) def test_nan_inf_mask(value): """ Test that nans and infs are properly masked [#267]. """ data = np.ones((9, 9)) mask = np.zeros_like(data, dtype=bool) data[4, 4] = value mask[4, 4] = True radius = 2.0 aper = CircularAperture((4, 4), radius) tbl = aperture_photometry(data, aper, mask=mask) desired = (np.pi * radius**2) - 1 assert_allclose(tbl['aperture_sum'], desired) def test_aperture_partial_overlap(): data = np.ones((20, 20)) error = np.ones((20, 20)) xypos = [(10, 10), (0, 0), (0, 19), (19, 0), (19, 19)] r = 5.0 aper = CircularAperture(xypos, r=r) tbl = aperture_photometry(data, aper, error=error) assert_allclose(tbl['aperture_sum'][0], np.pi * r**2) assert_array_less(tbl['aperture_sum'][1:], np.pi * r**2) unit = u.MJy / u.sr tbl = aperture_photometry(data * unit, aper, error=error * unit) assert_allclose(tbl['aperture_sum'][0].value, np.pi * r**2) assert_array_less(tbl['aperture_sum'][1:].value, np.pi * r**2) assert_array_less(tbl['aperture_sum_err'][1:].value, np.pi * r**2) assert tbl['aperture_sum'].unit == unit assert tbl['aperture_sum_err'].unit == unit def test_pixel_aperture_repr(): aper = CircularAperture((10, 20), r=3.0) assert ', r=3.0 deg)>') a_str = ('Aperture: SkyCircularAperture\npositions: \n' 'r: 3.0 deg') assert repr(aper) == a_repr assert str(aper) == a_str aper = SkyCircularAnnulus(s, r_in=3.0 * u.deg, r_out=5 * u.deg) a_repr = (', r_in=3.0 deg, r_out=5.0 deg)>') a_str = ('Aperture: SkyCircularAnnulus\npositions: \n' 'r_in: 3.0 deg\nr_out: 5.0 deg') assert repr(aper) == a_repr assert str(aper) == a_str aper = SkyEllipticalAperture(s, a=3 * u.deg, b=5 * u.deg, theta=15 * u.deg) a_repr = (', a=3.0 deg, b=5.0 deg, ' 'theta=15.0 deg)>') a_str = ('Aperture: SkyEllipticalAperture\npositions: \n' 'a: 3.0 deg\nb: 5.0 deg\ntheta: 15.0 deg') assert repr(aper) == a_repr assert str(aper) == a_str aper = SkyEllipticalAnnulus(s, a_in=3 * u.deg, a_out=5 * u.deg, b_out=3 * u.deg, theta=15 * u.deg) a_repr = (', a_in=3.0 deg, ' 'a_out=5.0 deg, b_in=1.8 deg, b_out=3.0 deg, ' 'theta=15.0 deg)>') a_str = ('Aperture: SkyEllipticalAnnulus\npositions: \n' 'a_in: 3.0 deg\na_out: 5.0 deg\nb_in: 1.8 deg\n' 'b_out: 3.0 deg\ntheta: 15.0 deg') assert repr(aper) == a_repr assert str(aper) == a_str aper = SkyRectangularAperture(s, w=3 * u.deg, h=5 * u.deg, theta=15 * u.deg) a_repr = (', w=3.0 deg, h=5.0 deg' ', theta=15.0 deg)>') a_str = ('Aperture: SkyRectangularAperture\npositions: \n' 'w: 3.0 deg\nh: 5.0 deg\ntheta: 15.0 deg') assert repr(aper) == a_repr assert str(aper) == a_str aper = SkyRectangularAnnulus(s, w_in=5 * u.deg, w_out=10 * u.deg, h_out=6 * u.deg, theta=15 * u.deg) a_repr = (', w_in=5.0 deg, ' 'w_out=10.0 deg, h_in=3.0 deg, h_out=6.0 deg, ' 'theta=15.0 deg)>') a_str = ('Aperture: SkyRectangularAnnulus\npositions: \n' 'w_in: 5.0 deg\nw_out: 10.0 deg\nh_in: 3.0 deg\n' 'h_out: 6.0 deg\ntheta: 15.0 deg') assert repr(aper) == a_repr assert str(aper) == a_str def test_rectangular_bbox(): # test odd sizes width = 7 height = 3 a = RectangularAperture((50, 50), w=width, h=height, theta=0) assert a.bbox.shape == (height, width) a = RectangularAperture((50.5, 50.5), w=width, h=height, theta=0) assert a.bbox.shape == (height + 1, width + 1) a = RectangularAperture((50, 50), w=width, h=height, theta=np.deg2rad(90.0)) assert a.bbox.shape == (width, height) # test even sizes width = 8 height = 4 a = RectangularAperture((50, 50), w=width, h=height, theta=0) assert a.bbox.shape == (height + 1, width + 1) a = RectangularAperture((50.5, 50.5), w=width, h=height, theta=0) assert a.bbox.shape == (height, width) a = RectangularAperture((50.5, 50.5), w=width, h=height, theta=np.deg2rad(90.0)) assert a.bbox.shape == (width, height) def test_elliptical_bbox(): # integer axes a = 7 b = 3 ap = EllipticalAperture((50, 50), a=a, b=b, theta=0) assert ap.bbox.shape == (2 * b + 1, 2 * a + 1) ap = EllipticalAperture((50.5, 50.5), a=a, b=b, theta=0) assert ap.bbox.shape == (2 * b, 2 * a) ap = EllipticalAperture((50, 50), a=a, b=b, theta=np.deg2rad(90.0)) assert ap.bbox.shape == (2 * a + 1, 2 * b + 1) # fractional axes a = 7.5 b = 4.5 ap = EllipticalAperture((50, 50), a=a, b=b, theta=0) assert ap.bbox.shape == (2 * b, 2 * a) ap = EllipticalAperture((50.5, 50.5), a=a, b=b, theta=0) assert ap.bbox.shape == (2 * b + 1, 2 * a + 1) ap = EllipticalAperture((50, 50), a=a, b=b, theta=np.deg2rad(90.0)) assert ap.bbox.shape == (2 * a, 2 * b) @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') @pytest.mark.parametrize('wcs_type', ['wcs', 'gwcs']) def test_to_sky_pixel(wcs_type): data = make_4gaussians_image() if wcs_type == 'wcs': wcs = make_wcs(data.shape) elif wcs_type == 'gwcs': wcs = make_gwcs(data.shape) ap = CircularAperture(((12.3, 15.7), (48.19, 98.14)), r=3.14) ap2 = ap.to_sky(wcs).to_pixel(wcs) assert_allclose(ap.positions, ap2.positions) assert_allclose(ap.r, ap2.r) ap = CircularAnnulus(((12.3, 15.7), (48.19, 98.14)), r_in=3.14, r_out=5.32) ap2 = ap.to_sky(wcs).to_pixel(wcs) assert_allclose(ap.positions, ap2.positions) assert_allclose(ap.r_in, ap2.r_in) assert_allclose(ap.r_out, ap2.r_out) ap = EllipticalAperture(((12.3, 15.7), (48.19, 98.14)), a=3.14, b=5.32, theta=np.deg2rad(103.0)) ap2 = ap.to_sky(wcs).to_pixel(wcs) assert_allclose(ap.positions, ap2.positions) assert_allclose(ap.a, ap2.a) assert_allclose(ap.b, ap2.b) assert_allclose(ap.theta, ap2.theta) ap = EllipticalAnnulus(((12.3, 15.7), (48.19, 98.14)), a_in=3.14, a_out=15.32, b_out=4.89, theta=np.deg2rad(103.0)) ap2 = ap.to_sky(wcs).to_pixel(wcs) assert_allclose(ap.positions, ap2.positions) assert_allclose(ap.a_in, ap2.a_in) assert_allclose(ap.a_out, ap2.a_out) assert_allclose(ap.b_out, ap2.b_out) assert_allclose(ap.theta, ap2.theta) ap = RectangularAperture(((12.3, 15.7), (48.19, 98.14)), w=3.14, h=5.32, theta=np.deg2rad(103.0)) ap2 = ap.to_sky(wcs).to_pixel(wcs) assert_allclose(ap.positions, ap2.positions) assert_allclose(ap.w, ap2.w) assert_allclose(ap.h, ap2.h) assert_allclose(ap.theta, ap2.theta) ap = RectangularAnnulus(((12.3, 15.7), (48.19, 98.14)), w_in=3.14, w_out=15.32, h_out=4.89, theta=np.deg2rad(103.0)) ap2 = ap.to_sky(wcs).to_pixel(wcs) assert_allclose(ap.positions, ap2.positions) assert_allclose(ap.w_in, ap2.w_in) assert_allclose(ap.w_out, ap2.w_out) assert_allclose(ap.h_out, ap2.h_out) assert_allclose(ap.theta, ap2.theta) def test_scalar_aperture(): """ Regression test to check that length-1 aperture list appends a "_0" on the column names to be consistent with list inputs. """ data = np.ones((20, 20), dtype=float) ap = CircularAperture((10, 10), r=3.0) colnames1 = aperture_photometry(data, ap, error=data).colnames assert (colnames1 == ['id', 'xcenter', 'ycenter', 'aperture_sum', 'aperture_sum_err']) colnames2 = aperture_photometry(data, [ap], error=data).colnames assert (colnames2 == ['id', 'xcenter', 'ycenter', 'aperture_sum_0', 'aperture_sum_err_0']) colnames3 = aperture_photometry(data, [ap, ap], error=data).colnames assert (colnames3 == ['id', 'xcenter', 'ycenter', 'aperture_sum_0', 'aperture_sum_err_0', 'aperture_sum_1', 'aperture_sum_err_1']) def test_nan_in_bbox(): """ Regression test that non-finite data values outside of the aperture mask but within the bounding box do not affect the photometry. """ data1 = np.ones((101, 101)) data2 = data1.copy() data1[33, 33] = np.nan data1[67, 67] = np.inf data1[33, 67] = -np.inf data1[22, 22] = np.nan data1[22, 23] = np.inf error = data1.copy() aper1 = CircularAperture((50, 50), r=20.0) aper2 = CircularAperture((5, 5), r=20.0) tbl1 = aperture_photometry(data1, aper1, error=error) tbl2 = aperture_photometry(data2, aper1, error=error) assert_allclose(tbl1['aperture_sum'], tbl2['aperture_sum']) assert_allclose(tbl1['aperture_sum_err'], tbl2['aperture_sum_err']) tbl3 = aperture_photometry(data1, aper2, error=error) tbl4 = aperture_photometry(data2, aper2, error=error) assert_allclose(tbl3['aperture_sum'], tbl4['aperture_sum']) assert_allclose(tbl3['aperture_sum_err'], tbl4['aperture_sum_err']) def test_scalar_skycoord(): """ Regression test to check that scalar SkyCoords are added to the table as a length-1 SkyCoord array. """ data = make_4gaussians_image() wcs = make_wcs(data.shape) skycoord = wcs.pixel_to_world(90, 60) aper = SkyCircularAperture(skycoord, r=0.1 * u.arcsec) tbl = aperture_photometry(data, aper, wcs=wcs) assert isinstance(tbl['sky_center'], SkyCoord) @pytest.mark.parametrize('units', [False, True]) def test_nddata_input(units): data = np.arange(400).reshape((20, 20)) error = np.sqrt(data) mask = np.zeros((20, 20), dtype=bool) mask[8:13, 8:13] = True if units: unit = u.Jy data = data * unit error = error * unit else: unit = None wcs = make_wcs(data.shape) skycoord = wcs.pixel_to_world(10, 10) aper = SkyCircularAperture(skycoord, r=0.7 * u.arcsec) tbl1 = aperture_photometry(data, aper, error=error, mask=mask, wcs=wcs) uncertainty = StdDevUncertainty(error) nddata = NDData(data, uncertainty=uncertainty, mask=mask, wcs=wcs, unit=unit) tbl2 = aperture_photometry(nddata, aper) for column in tbl1.columns: if column == 'sky_center': # cannot test SkyCoord equality continue assert_allclose(tbl1[column], tbl2[column]) match = 'keyword is be ignored. Its value is obtained from the input' with pytest.warns(AstropyUserWarning, match=match): aperture_photometry(nddata, aper, error=error) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class BaseTestRegionPhotometry: def test_region_matches_aperture(self): data = np.ones((40, 40), dtype=float) error = np.ones(data.shape, dtype=float) region_tables = [ aperture_photometry(data, self.region, method='center', error=error), aperture_photometry(data, self.region, method='subpixel', subpixels=12, error=error), aperture_photometry(data, self.region, method='exact', error=error), ] aperture_tables = [ aperture_photometry(data, self.aperture, method='center', error=error), aperture_photometry(data, self.aperture, method='subpixel', subpixels=12, error=error), aperture_photometry(data, self.aperture, method='exact', error=error), ] for reg_table, ap_table in zip(region_tables, aperture_tables, strict=True): assert_allclose(reg_table['aperture_sum'], ap_table['aperture_sum']) if isinstance(self.aperture, (RectangularAperture, RectangularAnnulus)): for reg_table, ap_table in zip(region_tables, aperture_tables, strict=True): assert_allclose(reg_table['aperture_sum_err'], ap_table['aperture_sum_err']) def test_invalid_inputs(): data = np.ones((11, 11)) aper = CircularAperture((5, 5), r=3) wcs = make_wcs(data.shape) sky_aper = aper.to_sky(wcs=wcs) match = 'A WCS transform must be defined' with pytest.raises(ValueError, match=match): aperture_photometry(data, sky_aper) aper2 = CircularAperture((7, 7), r=3) sky_aper2 = aper2.to_sky(wcs=wcs) apers = [aper, aper2] sky_apers = [sky_aper, sky_aper2] match = 'Input apertures must all have identical positions' with pytest.raises(ValueError, match=match): aperture_photometry(data, apers) with pytest.raises(ValueError, match=match): aperture_photometry(data, sky_apers, wcs=wcs) data = np.ones((11, 11)) aper = CircularAperture((5, 5), r=3) match = 'subpixels must be a strictly positive integer' with pytest.raises(ValueError, match=match): aperture_photometry(data, aper, method='subpixel', subpixels=0) with pytest.raises(ValueError, match=match): aperture_photometry(data, aper, method='subpixel', subpixels=-1) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestCircleRegionPhotometry(BaseTestRegionPhotometry): def setup_class(self): from regions import CirclePixelRegion, PixCoord position = (20.0, 20.0) r = 10.0 self.region = CirclePixelRegion(PixCoord(*position), r) self.aperture = CircularAperture(position, r) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestCircleAnnulusRegionPhotometry(BaseTestRegionPhotometry): def setup_class(self): from regions import CircleAnnulusPixelRegion, PixCoord position = (20.0, 20.0) r_in = 8.0 r_out = 10.0 self.region = CircleAnnulusPixelRegion(PixCoord(*position), r_in, r_out) self.aperture = CircularAnnulus(position, r_in, r_out) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestEllipseRegionPhotometry(BaseTestRegionPhotometry): def setup_class(self): from regions import EllipsePixelRegion, PixCoord position = (20.0, 20.0) a = 10.0 b = 5.0 theta = (-np.pi / 4.0) * u.rad self.region = EllipsePixelRegion(PixCoord(*position), a * 2, b * 2, theta) self.aperture = EllipticalAperture(position, a, b, theta=theta) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestEllipseAnnulusRegionPhotometry(BaseTestRegionPhotometry): def setup_class(self): from regions import EllipseAnnulusPixelRegion, PixCoord position = (20.0, 20.0) a_in = 5.0 a_out = 8.0 b_in = 3.0 b_out = 5.0 theta = (-np.pi / 4.0) * u.rad self.region = EllipseAnnulusPixelRegion(PixCoord(*position), a_in * 2, a_out * 2, b_in * 2, b_out * 2, theta) self.aperture = EllipticalAnnulus(position, a_in, a_out, b_out, b_in, theta=theta) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestRectangleRegionPhotometry(BaseTestRegionPhotometry): def setup_class(self): from regions import PixCoord, RectanglePixelRegion position = (20.0, 20.0) h = 5.0 w = 8.0 theta = (np.pi / 4.0) * u.rad self.region = RectanglePixelRegion(PixCoord(*position), w, h, theta) self.aperture = RectangularAperture(position, w, h, theta) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') class TestRectangleAnnulusRegionPhotometry(BaseTestRegionPhotometry): def setup_class(self): from regions import PixCoord, RectangleAnnulusPixelRegion position = (20.0, 20.0) h_out = 8.0 w_in = 8.0 w_out = 12.0 h_in = w_in * h_out / w_out theta = (np.pi / 8.0) * u.rad self.region = RectangleAnnulusPixelRegion(PixCoord(*position), w_in, w_out, h_in, h_out, theta) self.aperture = RectangularAnnulus(position, w_in, w_out, h_out, h_in, theta) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_unsupported_region_input(): from regions import PixCoord, PolygonPixelRegion region = PolygonPixelRegion(vertices=PixCoord(x=[1, 2, 3], y=[1, 1, 2])) data = np.ones((10, 10)) match = r'Cannot convert .* to an Aperture object' with pytest.raises(TypeError, match=match): aperture_photometry(data, region) def test_aperture_metadata(): x = [10, 20, 3] y = [3, 5, 10] xypos = list(zip(x, y, strict=False)) a1 = CircularAperture(xypos, r=3) a2 = CircularAperture(xypos, r=4) a3 = CircularAnnulus(xypos, 5, 10) a4 = EllipticalAperture(xypos, 10, 5, theta=10 * u.deg) a5 = EllipticalAnnulus(xypos, a_in=5, a_out=10, b_in=3, b_out=5, theta=20 * u.deg) a6 = RectangularAperture(xypos, 10, 5, theta=30 * u.deg) a7 = RectangularAnnulus(xypos, w_in=5, w_out=10, h_in=3, h_out=5, theta=40 * u.deg) apers = (a1, a2, a3, a4, a5, a6, a7) data = np.ones((50, 50)) tbl = aperture_photometry(data, apers) for i, aper in enumerate(apers): assert tbl.meta[f'aperture{i}'] == aper.__class__.__name__ params = aper._params for param in params: if param != 'positions': assert tbl.meta[f'aperture{i}_{param}'] == getattr(aper, param) wcs = make_wcs(data.shape) skycoord = wcs.pixel_to_world(10, 10) unit = u.arcsec saper = SkyEllipticalAnnulus(skycoord, a_in=0.1 * unit, a_out=0.2 * unit, b_in=0.05 * unit, b_out=0.1 * unit, theta=10 * u.deg) tbl = aperture_photometry(data, saper, wcs=wcs) assert tbl.meta['aperture'] == saper.__class__.__name__ assert tbl.meta['aperture_a_in'] == saper.a_in assert tbl.meta['aperture_a_out'] == saper.a_out assert tbl.meta['aperture_b_out'] == saper.b_out assert tbl.meta['aperture_theta'] == saper.theta ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/tests/test_rectangle.py0000644000175100001660000001526714755160622023575 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the rectangle module. """ import astropy.units as u import numpy as np import pytest from astropy.coordinates import Angle, SkyCoord from astropy.tests.helper import assert_quantity_allclose from photutils.aperture.rectangle import (RectangularAnnulus, RectangularAperture, SkyRectangularAnnulus, SkyRectangularAperture) from photutils.aperture.tests.test_aperture_common import BaseTestAperture POSITIONS = [(10, 20), (30, 40), (50, 60), (70, 80)] RA, DEC = np.transpose(POSITIONS) SKYCOORD = SkyCoord(ra=RA, dec=DEC, unit='deg') UNIT = u.arcsec RADII = (0.0, -1.0, -np.inf) class TestRectangularAperture(BaseTestAperture): aperture = RectangularAperture(POSITIONS, w=10.0, h=5.0, theta=np.pi / 2.0) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): match = "'w' must be a positive scalar" with pytest.raises(ValueError, match=match): RectangularAperture(POSITIONS, w=radius, h=5.0, theta=np.pi / 2.0) match = "'h' must be a positive scalar" with pytest.raises(ValueError, match=match): RectangularAperture(POSITIONS, w=10.0, h=radius, theta=np.pi / 2.0) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.w = 20.0 assert aper != self.aperture def test_theta(self): assert isinstance(self.aperture.theta, u.Quantity) assert self.aperture.theta.unit == u.rad class TestRectangularAnnulus(BaseTestAperture): aperture = RectangularAnnulus(POSITIONS, w_in=10.0, w_out=20.0, h_out=17, theta=np.pi / 3) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): match = "'w_in' must be a positive scalar" with pytest.raises(ValueError, match=match): RectangularAnnulus(POSITIONS, w_in=radius, w_out=20.0, h_out=17, theta=np.pi / 3) match = '"w_out" must be greater than "w_in"' with pytest.raises(ValueError, match=match): RectangularAnnulus(POSITIONS, w_in=10.0, w_out=radius, h_out=17, theta=np.pi / 3) match = "'h_out' must be a positive scalar" with pytest.raises(ValueError, match=match): RectangularAnnulus(POSITIONS, w_in=10.0, w_out=20.0, h_out=radius, theta=np.pi / 3) match = "'h_in' must be a positive scalar" with pytest.raises(ValueError, match=match): RectangularAnnulus(POSITIONS, w_in=10.0, w_out=20.0, h_out=17, h_in=radius, theta=np.pi / 3) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.w_in = 2.0 assert aper != self.aperture def test_theta(self): assert isinstance(self.aperture.theta, u.Quantity) assert self.aperture.theta.unit == u.rad class TestSkyRectangularAperture(BaseTestAperture): aperture = SkyRectangularAperture(SKYCOORD, w=10.0 * UNIT, h=5.0 * UNIT, theta=30 * u.deg) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): match = "'w' must be greater than zero" with pytest.raises(ValueError, match=match): SkyRectangularAperture(SKYCOORD, w=radius * UNIT, h=5.0 * UNIT, theta=30 * u.deg) match = "'h' must be greater than zero" with pytest.raises(ValueError, match=match): SkyRectangularAperture(SKYCOORD, w=10.0 * UNIT, h=radius * UNIT, theta=30 * u.deg) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.w = 20.0 * UNIT assert aper != self.aperture class TestSkyRectangularAnnulus(BaseTestAperture): aperture = SkyRectangularAnnulus(SKYCOORD, w_in=10.0 * UNIT, w_out=20.0 * UNIT, h_out=17.0 * UNIT, theta=60 * u.deg) @staticmethod @pytest.mark.parametrize('radius', RADII) def test_invalid_params(radius): match = "'w_in' must be greater than zero" with pytest.raises(ValueError, match=match): SkyRectangularAnnulus(SKYCOORD, w_in=radius * UNIT, w_out=20.0 * UNIT, h_out=17.0 * UNIT, theta=60 * u.deg) match = '"w_out" must be greater than "w_in"' with pytest.raises(ValueError, match=match): SkyRectangularAnnulus(SKYCOORD, w_in=10.0 * UNIT, w_out=radius * UNIT, h_out=17.0 * UNIT, theta=60 * u.deg) match = "'h_out' must be greater than zero" with pytest.raises(ValueError, match=match): SkyRectangularAnnulus(SKYCOORD, w_in=10.0 * UNIT, w_out=20.0 * UNIT, h_out=radius * UNIT, theta=60 * u.deg) match = "'h_in' must be greater than zero" with pytest.raises(ValueError, match=match): SkyRectangularAnnulus(SKYCOORD, w_in=10.0 * UNIT, w_out=20.0 * UNIT, h_out=17.0 * UNIT, h_in=radius * UNIT, theta=60 * u.deg) def test_copy_eq(self): aper = self.aperture.copy() assert aper == self.aperture aper.w_in = 2.0 * UNIT assert aper != self.aperture def test_rectangle_theta_quantity(): aper1 = RectangularAperture(POSITIONS, w=10.0, h=5.0, theta=np.pi / 2.0) theta = u.Quantity(90 * u.deg) aper2 = RectangularAperture(POSITIONS, w=10.0, h=5.0, theta=theta) theta = Angle(90 * u.deg) aper3 = RectangularAperture(POSITIONS, w=10.0, h=5.0, theta=theta) assert_quantity_allclose(aper1.theta, aper2.theta) assert_quantity_allclose(aper1.theta, aper3.theta) def test_rectangle_annulus_theta_quantity(): aper1 = RectangularAnnulus(POSITIONS, w_in=10.0, w_out=20.0, h_out=17, theta=np.pi / 3) theta = u.Quantity(60 * u.deg) aper2 = RectangularAnnulus(POSITIONS, w_in=10.0, w_out=20.0, h_out=17, theta=theta) theta = Angle(60 * u.deg) aper3 = RectangularAnnulus(POSITIONS, w_in=10.0, w_out=20.0, h_out=17, theta=theta) assert_quantity_allclose(aper1.theta, aper2.theta) assert_quantity_allclose(aper1.theta, aper3.theta) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/aperture/tests/test_stats.py0000644000175100001660000004204514755160622022761 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the stats module. """ import astropy.units as u import numpy as np import pytest from astropy.nddata import NDData, StdDevUncertainty from astropy.stats import SigmaClip from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_equal from photutils.aperture.circle import CircularAnnulus, CircularAperture from photutils.aperture.ellipse import (EllipticalAnnulus, EllipticalAperture, SkyEllipticalAnnulus) from photutils.aperture.rectangle import (RectangularAnnulus, RectangularAperture) from photutils.aperture.stats import ApertureStats from photutils.datasets import make_100gaussians_image, make_wcs from photutils.utils._optional_deps import HAS_REGIONS class TestApertureStats: data = make_100gaussians_image() error = np.sqrt(np.abs(data)) wcs = make_wcs(data.shape) positions = ((145.1, 168.3), (84.7, 224.1), (48.3, 200.3)) aperture = CircularAperture(positions, r=5) sigclip = SigmaClip(sigma=3.0, maxiters=10) apstats1 = ApertureStats(data, aperture, error=error, wcs=wcs, sigma_clip=None) apstats2 = ApertureStats(data, aperture, error=error, wcs=wcs, sigma_clip=sigclip) unit = u.Jy apstats1_units = ApertureStats(data * u.Jy, aperture, error=error * u.Jy, wcs=wcs, sigma_clip=None) apstats2_units = ApertureStats(data * u.Jy, aperture, error=error * u.Jy, wcs=wcs, sigma_clip=sigclip) @pytest.mark.parametrize('with_units', [True, False]) @pytest.mark.parametrize('with_sigmaclip', [True, False]) def test_properties(self, with_units, with_sigmaclip): apstats = [self.apstats1.copy(), self.apstats2.copy(), self.apstats1_units.copy(), self.apstats2_units.copy()] index = [1, 3] if with_sigmaclip else [0, 2] index = index[1] if with_units else index[0] apstats1 = apstats[index] apstats2 = apstats1.copy() idx = 1 scalar_props = ('isscalar', 'n_apertures') # evaluate (cache) properties before slice for prop in apstats1.properties: _ = getattr(apstats1, prop) apstats3 = apstats1[idx] for prop in apstats1.properties: if prop in scalar_props: continue assert_equal(getattr(apstats1, prop)[idx], getattr(apstats3, prop)) # slice catalog before evaluating catalog properties apstats4 = apstats2[idx] for prop in apstats1.properties: if prop in scalar_props: continue assert_equal(getattr(apstats4, prop), getattr(apstats1, prop)[idx]) def test_skyaperture(self): pix_apstats = ApertureStats(self.data, self.aperture, wcs=self.wcs) skyaper = self.aperture.to_sky(self.wcs) sky_apstats = ApertureStats(self.data, skyaper, wcs=self.wcs) exclude_props = ('bbox', 'error_sumcutout', 'sum_error', 'sky_centroid', 'sky_centroid_icrs') for prop in pix_apstats.properties: if prop in exclude_props: continue assert_allclose(getattr(pix_apstats, prop), getattr(sky_apstats, prop), atol=1e-7) 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_sumcutout') for prop in props: assert set(getattr(apstats, prop)) == {None} assert np.all(np.isnan(apstats.sum_err)) assert set(apstats._variance_cutout) == {None} apstats = ApertureStats(self.data, self.aperture, sum_method='center') assert set(apstats._variance_cutout_center) == {None} @pytest.mark.parametrize('sum_method', ['exact', 'subpixel']) def test_sum_method(self, sum_method): apstats1 = ApertureStats(self.data, self.aperture, error=self.error, sum_method='center') apstats2 = ApertureStats(self.data, self.aperture, error=self.error, sum_method=sum_method, subpixels=4) scalar_props = ('isscalar', 'n_apertures') # evaluate (cache) properties before slice for prop in apstats1.properties: if prop in scalar_props: continue if 'sum' in prop: # test that these properties are not equal with pytest.raises(AssertionError): assert_equal(getattr(apstats1, prop), getattr(apstats2, prop)) else: assert_equal(getattr(apstats1, prop), getattr(apstats2, prop)) def test_sum_method_photometry(self): for method in ('center', 'exact', 'subpixel'): subpixels = 4 apstats = ApertureStats(self.data, self.aperture, error=self.error, sum_method=method, subpixels=subpixels) apsum, apsum_err = self.aperture.do_photometry(self.data, self.error, method=method, subpixels=subpixels) assert_allclose(apstats.sum, apsum) assert_allclose(apstats.sum_err, apsum_err) def test_mask(self): mask = np.zeros(self.data.shape, dtype=bool) mask[225:240, 80:90] = True # partially mask id=2 mask[190:210, 40:60] = True # completely mask id=3 apstats = ApertureStats(self.data, self.aperture, mask=mask, error=self.error) # id=2 is partially masked assert apstats[1].sum < self.apstats1[1].sum assert apstats[1].sum_err < self.apstats1[1].sum_err exclude = ('isscalar', 'n_apertures', 'sky_centroid', 'sky_centroid_icrs') apstats1 = apstats[2] for prop in apstats1.properties: if (prop in exclude or 'bbox' in prop or 'cutout' in prop or 'moments' in prop): continue assert np.all(np.isnan(getattr(apstats1, prop))) # test that mask=None is the same as mask=np.ma.nomask apstats1 = ApertureStats(self.data, self.aperture, mask=None) apstats2 = ApertureStats(self.data, self.aperture, mask=np.ma.nomask) assert_equal(apstats1.centroid, apstats2.centroid) def test_local_bkg(self): data = np.ones(self.data.shape) * 100.0 local_bkg = (10, 20, 30) apstats = ApertureStats(data, self.aperture, local_bkg=local_bkg) for i, locbkg in enumerate(local_bkg): apstats0 = ApertureStats(data - locbkg, self.aperture[i], local_bkg=None) for prop in apstats.properties: assert_equal(getattr(apstats[i], prop), getattr(apstats0, prop)) # test broadcasting local_bkg = (12, 12, 12) apstats1 = ApertureStats(data, self.aperture, local_bkg=local_bkg) apstats2 = ApertureStats(data, self.aperture, local_bkg=local_bkg[0]) assert_equal(apstats1.sum, apstats2.sum) match = 'local_bkg must be scalar or have the same length as the' with pytest.raises(ValueError, match=match): _ = ApertureStats(data, self.aperture, local_bkg=(10, 20)) match = 'local_bkg must not contain any non-finite' with pytest.raises(ValueError, match=match): _ = ApertureStats(data, self.aperture[0:2], local_bkg=(10, np.nan)) with pytest.raises(ValueError, match=match): _ = ApertureStats(data, self.aperture[0:2], local_bkg=(-np.inf, 10)) match = 'local_bkg must be a 1D array' with pytest.raises(ValueError, match=match): _ = ApertureStats(data, self.aperture[0:2], local_bkg=np.ones((3, 3))) def test_no_aperture_overlap(self): aperture = CircularAperture(((0, 0), (100, 100), (-100, -100)), r=5) apstats = ApertureStats(self.data, aperture) assert_equal(apstats._overlap, [True, True, False]) exclude = ('isscalar', 'n_apertures', 'sky_centroid', 'sky_centroid_icrs') apstats1 = apstats[2] for prop in apstats1.properties: if (prop in exclude or 'bbox' in prop or 'cutout' in prop or 'moments' in prop): continue assert np.all(np.isnan(getattr(apstats1, prop))) def test_to_table(self): tbl = self.apstats1.to_table() assert tbl.colnames == self.apstats1.default_columns assert len(tbl) == len(self.apstats1) == 3 columns = ['id', 'min', 'max', 'mean', 'median', 'std', 'sum'] tbl = self.apstats1.to_table(columns=columns) assert tbl.colnames == columns assert len(tbl) == len(self.apstats1) == 3 def test_slicing(self): apstats = self.apstats1 _ = apstats.to_table() apstat0 = apstats[1] assert apstat0.n_apertures == 1 assert apstat0.ids == np.array([2]) apstat1 = apstats.get_id(2) assert apstat1.n_apertures == 1 assert apstat0.sum_aper_area == apstat1.sum_aper_area apstat0 = apstats[0:1] assert len(apstat0) == 1 apstat0 = apstats[0:2] assert len(apstat0) == 2 apstat0 = apstats[0:3] assert len(apstat0) == 3 apstat0 = apstats[1:] apstat1 = apstats.get_ids([1, 2]) assert len(apstat0) == len(apstat1) == 2 apstat0 = apstats[1:] apstat1 = apstats.get_ids([1, 2]) assert len(apstat0) == len(apstat1) == 2 apstat0 = apstats[[2, 1, 0]] apstat1 = apstats.get_ids([3, 2, 1]) assert len(apstat0) == len(apstat1) == 3 assert_equal(apstat0.ids, [3, 2, 1]) assert_equal(apstat1.ids, [3, 2, 1]) # test get_ids when ids are not sorted apstat0 = apstats[[2, 1, 0]] apstat1 = apstat0.get_ids(2) assert apstat1.ids == 2 mask = apstats.id >= 2 apstat0 = apstats[mask] assert len(apstat0) == 2 assert_equal(apstat0.ids, [2, 3]) # test iter for (i, apstat) in enumerate(apstats): assert apstat.isscalar assert apstat.id == (i + 1) 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.get_ids([-1, 0]) def test_scalar_aperture_stats(self): apstats = self.apstats1[0] assert apstats.n_apertures == 1 assert apstats.ids == np.array([1]) tbl = apstats.to_table() assert len(tbl) == 1 def test_invalid_inputs(self): match = 'aperture must be an Aperture or Region object' with pytest.raises(TypeError, match=match): ApertureStats(self.data, 10.0) match = 'sigma_clip must be a SigmaClip instance' with pytest.raises(TypeError, match=match): ApertureStats(self.data, self.aperture, sigma_clip=10) match = 'error must be a 2D array' with pytest.raises(ValueError, match=match): ApertureStats(self.data, self.aperture, error=10.0) match = 'error must be a 2D array' with pytest.raises(ValueError, match=match): ApertureStats(self.data, self.aperture, error=np.ones(3)) match = 'data and error must have the same shape' with pytest.raises(ValueError, match=match): ApertureStats(self.data, self.aperture, error=np.ones((3, 3))) def test_repr_str(self): assert repr(self.apstats1) == str(self.apstats1) assert 'Length: 3' in repr(self.apstats1) def test_data_dtype(self): """ Regression test that input ``data`` with int dtype does not raise UFuncTypeError due to subtraction of float array from int array. """ data = np.ones((25, 25), dtype=np.uint16) aper = CircularAperture((12, 12), 5) apstats = ApertureStats(data, aper) assert apstats.min == 1.0 assert apstats.max == 1.0 assert apstats.mean == 1.0 assert apstats.xcentroid == 12.0 assert apstats.ycentroid == 12.0 @pytest.mark.parametrize('with_units', [True, False]) def test_nddata_input(self, with_units): mask = np.zeros(self.data.shape, dtype=bool) mask[225:240, 80:90] = True # partially mask id=2 data = self.data error = self.error if with_units: unit = u.Jy data <<= unit error <<= unit else: unit = None apstats1 = ApertureStats(data, self.aperture, error=error, mask=mask, wcs=self.wcs, sigma_clip=None) uncertainty = StdDevUncertainty(self.error) nddata = NDData(self.data, uncertainty=uncertainty, mask=mask, wcs=self.wcs, unit=unit) apstats2 = ApertureStats(nddata, self.aperture, sigma_clip=None) assert_allclose(apstats1.xcentroid, apstats2.xcentroid) assert_allclose(apstats1.ycentroid, apstats2.ycentroid) assert_allclose(apstats1.sum, apstats2.sum) if with_units: assert apstats1.sum.unit == unit match = 'keyword will be ignored' nddata = NDData(self.data, uncertainty=uncertainty, mask=mask, wcs=self.wcs, unit=unit) with pytest.warns(AstropyUserWarning, match=match): ApertureStats(nddata, self.aperture, mask=mask) def test_tiny_source(self): data = np.zeros((21, 21)) data[5, 5] = 1.0 aperture = CircularAperture(((5, 5), (15, 15)), r=1) apstats = ApertureStats(data, aperture) assert_allclose(apstats.sum, (1.0, 0.0)) assert_allclose(apstats[0].covariance, [(1 / 12, 0), (0, 1 / 12)] * u.pix**2) assert_allclose(apstats[1].covariance, [(np.nan, np.nan), (np.nan, np.nan)] * u.pix**2) assert_allclose(apstats.fwhm, [0.67977799, np.nan] * u.pix) @pytest.mark.skipif(not HAS_REGIONS, reason='regions is required') def test_aperture_stats_region(): from regions import CirclePixelRegion, PixCoord region = CirclePixelRegion(center=PixCoord(5, 5), radius=3) aperture = CircularAperture((5, 5), r=3) data = np.ones((10, 10)) apstats1 = ApertureStats(data, region) apstats2 = ApertureStats(data, aperture) tbl = apstats1.to_table() for colname in tbl.colnames: val1 = getattr(apstats1, colname) if val1 is not None: assert_allclose(val1, getattr(apstats2, colname)) def test_aperture_metadata(): x = [10, 20, 3] y = [3, 5, 10] xypos = list(zip(x, y, strict=False)) a1 = CircularAperture(xypos, r=3) a2 = CircularAperture(xypos, r=4) a3 = CircularAnnulus(xypos, 5, 10) a4 = EllipticalAperture(xypos, 10, 5, theta=10 * u.deg) a5 = EllipticalAnnulus(xypos, a_in=5, a_out=10, b_in=3, b_out=5, theta=20 * u.deg) a6 = RectangularAperture(xypos, 10, 5, theta=30 * u.deg) a7 = RectangularAnnulus(xypos, w_in=5, w_out=10, h_in=3, h_out=5, theta=40 * u.deg) apers = (a1, a2, a3, a4, a5, a6, a7) data = np.ones((50, 50)) for aper in apers: apstats = ApertureStats(data, aper) tbl = apstats.to_table() assert tbl.meta['aperture'] == aper.__class__.__name__ params = aper._params for param in params: if param != 'positions': assert tbl.meta[f'aperture_{param}'] == getattr(aper, param) wcs = make_wcs(data.shape) skycoord = wcs.pixel_to_world(10, 10) unit = u.arcsec saper = SkyEllipticalAnnulus(skycoord, a_in=0.1 * unit, a_out=0.2 * unit, b_in=0.05 * unit, b_out=0.1 * unit, theta=10 * u.deg) apstats = ApertureStats(data, saper, wcs=wcs) tbl = apstats.to_table() assert tbl.meta['aperture'] == saper.__class__.__name__ assert tbl.meta['aperture_a_in'] == saper.a_in assert tbl.meta['aperture_a_out'] == saper.a_out assert tbl.meta['aperture_b_out'] == saper.b_out assert tbl.meta['aperture_theta'] == saper.theta ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6939266 photutils-2.2.0/photutils/background/0000755000175100001660000000000014755160634017336 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/background/__init__.py0000644000175100001660000000054114755160622021444 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools to estimate the background and background RMS in an image. """ from .background_2d import * # noqa: F401, F403 from .core import * # noqa: F401, F403 from .interpolators import * # noqa: F401, F403 from .local_background import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/background/background_2d.py0000644000175100001660000011041614755160622022414 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines classes to estimate the 2D background and background RMS in an image. """ import warnings import astropy.units as u import numpy as np from astropy.nddata import NDData, block_replicate, reshape_as_blocks from astropy.stats import SigmaClip from astropy.utils import lazyproperty from astropy.utils.decorators import deprecated, deprecated_renamed_argument from astropy.utils.exceptions import AstropyUserWarning from scipy.ndimage import generic_filter from photutils.aperture import RectangularAperture from photutils.background.core import SExtractorBackground, StdBackgroundRMS from photutils.background.interpolators import (BkgIDWInterpolator, BkgZoomInterpolator) from photutils.utils import ShepardIDWInterpolator from photutils.utils._parameters import as_pair from photutils.utils._repr import make_repr from photutils.utils._stats import nanmedian, nanmin __all__ = ['Background2D'] __doctest_skip__ = ['Background2D'] class Background2D: """ Class to estimate a 2D background and background RMS noise in an image. The background is estimated using (sigma-clipped) statistics in each box of a grid that covers the input ``data`` to create a low-resolution, and possibly irregularly-gridded, background map. The final background map is calculated by interpolating the low-resolution background map. Invalid data values (i.e., NaN or inf) are automatically masked. .. note:: Better performance will generally be obtained if you have the `bottleneck`_ package installed. This acceleration also requires that the byte order of the input data array matches the byte order of the operating system. For example, the `astropy.io.fits` module loads data arrays as big-endian, even though most modern processors are little-endian. A big-endian array can be converted to native byte order ('=') in place using:: >>> data.byteswap(inplace=True) >>> data = data.view(data.dtype.newbyteorder('=')) One can also use, e.g.,:: >>> data = data.astype(float) but this will temporarily create a new copy of the array in memory. Parameters ---------- data : array_like or `~astropy.nddata.NDData` The 2D array from which to estimate the background and/or background RMS map. box_size : int or array_like (int) The box size along each axis. If ``box_size`` is a scalar then a square box of size ``box_size`` will be used. If ``box_size`` has two elements, they must be in ``(ny, nx)`` order. For best results, the box shape should be chosen such that the ``data`` are covered by an integer number of boxes in both dimensions. When this is not the case, see the ``edge_method`` keyword for more options. mask : array_like (bool), optional A boolean mask, with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from calculations. ``mask`` is intended to mask sources or bad pixels. Use ``coverage_mask`` to mask blank areas of an image. ``mask`` and ``coverage_mask`` differ only in that ``coverage_mask`` is applied to the output background and background RMS maps (see ``fill_value``). coverage_mask : array_like (bool), optional A boolean mask, with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. ``coverage_mask`` should be `True` where there is no coverage (i.e., no data) for a given pixel (e.g., blank areas in a mosaic image). It should not be used for bad pixels (in that case use ``mask`` instead). ``mask`` and ``coverage_mask`` differ only in that ``coverage_mask`` is applied to the output background and background RMS maps (see ``fill_value``). fill_value : float, optional The value used to fill the output background and background RMS maps where the input ``coverage_mask`` is `True`. exclude_percentile : float in the range of [0, 100], optional The percentage of masked pixels allowed in a box for it to be included in the low-resolution map. If a box has more than ``exclude_percentile`` percent of its pixels masked then it will be excluded from the low-resolution map. Masked pixels include those from the input ``mask`` and ``coverage_mask``, non-finite ``data`` values, any padded area at the data edges, and those resulting from any sigma clipping. Setting ``exclude_percentile=0`` will exclude boxes that have any that have any masked pixels. Note that completely masked boxes are always excluded. In general, ``exclude_percentile`` should be kept as low as possible to ensure there are a sufficient number of unmasked pixels in each box for reasonable statistical estimates. The default is 10.0. filter_size : int or array_like (int), optional The window size of the 2D median filter to apply to the low-resolution background map. If ``filter_size`` is a scalar then a square box of size ``filter_size`` will be used. If ``filter_size`` has two elements, they must be in ``(ny, nx)`` order. ``filter_size`` must be odd along both axes. A filter size of ``1`` (or ``(1, 1)``) means no filtering. filter_threshold : int, optional The threshold value for used for selective median filtering of the low-resolution 2D background map. The median filter will be applied to only the background boxes with values larger than ``filter_threshold``. Set to `None` to filter all boxes (default). edge_method : {'pad', 'crop'}, optional This keyword will be removed in a future version and the default version of ``'pad'`` will always be used. The ``'crop'`` option has been strongly discouraged for some time now. Its usage creates a undesirable scaling of the low-resolution maps that leads to incorrect results. The method used to determine how to handle the case where the image size is not an integer multiple of the ``box_size`` in either dimension. Both options will resize the image for internal calculations to give an exact multiple of ``box_size`` in both dimensions. * ``'pad'``: pad the image along the top and/or right edges. This is the default and recommended method. Ideally, the ``box_size`` should be chosen such that an integer number of boxes is only slightly larger than the ``data`` size to minimize the amount of padding. * ``'crop'``: crop the image along the top and/or right edges. This method should be used sparingly, and it may be deprecated in the future. Best results will occur when ``box_size`` is chosen such that an integer number of boxes is only slightly smaller than the ``data`` size to minimize the amount of cropping. sigma_clip : `astropy.stats.SigmaClip` instance, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=10``. bkg_estimator : callable, optional A callable object (a function or e.g., an instance of any `~photutils.background.BackgroundBase` subclass) used to estimate the background in each of the boxes. The callable object must take in a 2D `~numpy.ndarray` or `~numpy.ma.MaskedArray` and have an ``axis`` keyword. Internally, the background will be calculated along ``axis=1`` and in this case the callable object must return a 1D `~numpy.ndarray`, where np.nan values are used for masked pixels. If ``bkg_estimator`` includes sigma clipping, it will be ignored (use the ``sigma_clip`` keyword here to define sigma clipping). The default is an instance of `~photutils.background.SExtractorBackground`. bkgrms_estimator : callable, optional A callable object (a function or e.g., an instance of any `~photutils.background.BackgroundRMSBase` subclass) used to estimate the background RMS in each of the boxes. The callable object must take in a 2D `~numpy.ndarray` or `~numpy.ma.MaskedArray` and have an ``axis`` keyword. Internally, the background RMS will be calculated along ``axis=1`` and in this case the callable object must return a 1D `~numpy.ndarray`, where np.nan values are used for masked pixels. If ``bkgrms_estimator`` includes sigma clipping, it will be ignored (use the ``sigma_clip`` keyword here to define sigma clipping). The default is an instance of `~photutils.background.StdBackgroundRMS`. interpolator : callable, optional A callable object (a function or object) used to interpolate the low-resolution background or background RMS image to the full-size background or background RMS maps. The default is an instance of `BkgZoomInterpolator`, which uses the `scipy.ndimage.zoom` function. Notes ----- Better performance will generally be obtained if you have the `bottleneck`_ package installed. If there is only one background box element (i.e., ``box_size`` is the same size as (or larger than) the ``data``), then the background map will simply be a constant image. .. _bottleneck: https://github.com/pydata/bottleneck """ @deprecated_renamed_argument('edge_method', None, '2.0.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, edge_method='pad', sigma_clip=SigmaClip(sigma=3.0, maxiters=10), bkg_estimator=SExtractorBackground(sigma_clip=None), bkgrms_estimator=StdBackgroundRMS(sigma_clip=None), interpolator=BkgZoomInterpolator()): if isinstance(data, (u.Quantity, NDData)): # includes CCDData self._unit = data.unit data = data.data else: self._unit = None # this is a temporary instance variable to store the input data self._data = self._validate_array(data, 'data', shape=False) self._data_dtype = self._data.dtype self._mask = self._validate_array(mask, 'mask') 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, 1), upper_bound=data.shape) self.fill_value = fill_value if exclude_percentile < 0 or exclude_percentile > 100: raise ValueError('exclude_percentile must be between 0 and 100 ' '(inclusive).') self.exclude_percentile = exclude_percentile self.filter_size = as_pair('filter_size', filter_size, lower_bound=(0, 1), check_odd=True) self.filter_threshold = filter_threshold if edge_method not in ('pad', 'crop'): raise ValueError('edge_method must be "pad" or "crop"') self.edge_method = edge_method self.sigma_clip = sigma_clip self.interpolator = interpolator # we perform sigma clipping as a separate step to avoid # calling it twice for the background and background RMS bkg_estimator.sigma_clip = None bkgrms_estimator.sigma_clip = None self.bkg_estimator = bkg_estimator self.bkgrms_estimator = bkgrms_estimator self._box_npixels = None self._params = ('box_size', 'coverage_mask', 'fill_value', 'exclude_percentile', 'filter_size', 'filter_threshold', 'edge_method', 'sigma_clip', 'bkg_estimator', 'bkgrms_estimator', 'interpolator') # store the interpolator keyword arguments for later use # (before self._data is deleted in self._calculate_stats) self._interp_kwargs = {'shape': self._data.shape, 'dtype': self._data.dtype, 'box_size': self.box_size, 'edge_method': self.edge_method} # perform the initial calculations to avoid storing large data # arrays and to keep the memory usage minimal (self._bkg_stats, self._bkgrms_stats, self._ngood) = self._calculate_stats() # this is used to selectively filter the low-resolution maps self._min_bkg_stats = nanmin(self._bkg_stats) # store a mask of the excluded mesh values (NaNs) in the # low-resolution maps self._mesh_nan_mask = np.isnan(self._bkg_stats) # add keyword arguments needed for BkgZoomInterpolator # BkgIDWInterpolator upscales the mesh based only on the good # pixels in the low-resolution mesh if isinstance(self.interpolator, BkgIDWInterpolator): self._interp_kwargs['mesh_yxcen'] = self._calculate_mesh_yxcen() self._interp_kwargs['mesh_nan_mask'] = self._mesh_nan_mask def __repr__(self): ellipsis = ('coverage_mask',) return make_repr(self, self._params, ellipsis=ellipsis) def __str__(self): ellipsis = ('coverage_mask',) return make_repr(self, self._params, ellipsis=ellipsis, long=True) def _validate_array(self, array, name, shape=True): """ Validate the input data, mask, and coverage_mask arrays. """ if name in ('mask', 'coverage_mask') and array is np.ma.nomask: array = None if array is not None: array = np.asanyarray(array) if array.ndim != 2: raise ValueError(f'{name} must be a 2D array.') if shape and array.shape != self._data.shape: raise ValueError(f'data and {name} must have the same shape.') return array def _apply_units(self, data): """ Apply units to the data. The units are based on the units of the input ``data`` array. Parameters ---------- data : `~numpy.ndarray` The input data array. Returns ------- data : `~numpy.ndarray` The data array with units applied. """ if self._unit is not None: data <<= self._unit return data def _combine_input_masks(self): """ Combine the input mask and coverage_mask. """ if self._mask is None and self.coverage_mask is None: return None if self._mask is None: return self.coverage_mask if self.coverage_mask is None: return self._mask mask = np.logical_or(self._mask, self.coverage_mask) del self._mask return 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 invalid values (NaNs or infs), which ' 'were automatically masked.') if input_mask is None: if np.any(mask): warnings.warn(msg, AstropyUserWarning) return mask total_mask = np.logical_or(input_mask, mask) if input_mask is not None: condition = np.logical_and(np.logical_not(input_mask), mask) if np.any(condition): warnings.warn(msg, AstropyUserWarning) return total_mask @lazyproperty def _good_npixels_threshold(self): """ The minimum number of required unmasked pixels in a box used for it to be included in the low-resolution map. For exclude_percentile=0, only boxes where nmasked=0 will be included. For exclude_percentile=100, all boxes will be included *unless* they are completely masked. Boxes that are completely masked are always excluded. """ return (1 - (self.exclude_percentile / 100.0)) * self._box_npixels def _sigmaclip_boxes(self, data, axis): """ Sigma clip the boxes along the specified axis. This method sigma clips the boxes along the specified axis and returns the sigma-clipped data. The input ``data`` is typically a 4D array where the first two dimensions represent the y and x positions of the boxes and the last two dimensions represent the y and x positions within each box. We perform sigma clipping as a separate step to avoid performing sigma clipping for both the background and background RMS estimators. Parameters ---------- data : `~numpy.ndarray` The 4D array of box data. axis : int or tuple of int The axis or axes along which to sigma clip the data. Returns ------- data : `~numpy.ndarray` The sigma-clipped data. """ with warnings.catch_warnings(): warnings.simplefilter('ignore', category=AstropyUserWarning) if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False, copy=False) return data def _compute_box_statistics(self, data, axis=None): """ Compute the background and background RMS statistics in each box. Parameters ---------- data : `~numpy.ndarray` The 4D array of box data. axis : int or tuple of int, optional The axis or axes along which to compute the statistics. Returns ------- bkg : 2D `~numpy.ndarray` or float The background statistics in each box. bkgrms : 2D `~numpy.ndarray` or float The background RMS statistics in each box. """ data = self._sigmaclip_boxes(data, axis=axis) # make 2D arrays of the box statistics bkg = self.bkg_estimator(data, axis=axis) bkgrms = self.bkgrms_estimator(data, axis=axis) # mask boxes with too few unmasked pixels ngood = np.count_nonzero(~np.isnan(data), axis=axis) box_mask = ngood <= self._good_npixels_threshold if np.ndim(bkg) == 0: if box_mask: # single corner box # np.nan is float64; use np.float32 to prevent numpy from # promoting the output data dtype to float64 if the # input data is float32 bkg = np.float32(np.nan) bkgrms = np.float32(np.nan) else: bkg[box_mask] = np.nan bkgrms[box_mask] = np.nan return bkg, bkgrms, ngood def _calculate_stats(self): """ Calculate the background and background RMS statistics in each box. Parameters ---------- data : 2D `~numpy.ndarray` The 2D input data array. The data array is assumed to have been prepared by the ``_prepare_data`` method, where NaNs are used to mask invalid data values. Returns ------- bkg : 2D `~numpy.ndarray` The background statistics in each box. bkgrms : 2D `~numpy.ndarray` The background RMS statistics in each box. ngood : 2D `~numpy.ndarray` The number of unmasked pixels in each box. """ # if needed, copy the data to a float32 array to insert NaNs if self._data.dtype.kind != 'f': self._data = self._data.astype(np.float32) # automatically mask non-finite values that aren't already # masked and combine all masks mask = self._combine_all_masks(~np.isfinite(self._data)) self._box_npixels = np.prod(self.box_size) nboxes = self._data.shape // self.box_size y1, x1 = nboxes * self.box_size # core boxes - the part of the data array that is an integer # multiple of the box size # combine the last two axes for performance # Below we transform both the data and mask arrays to avoid # making multiple copies of the data (one to insert NaN and # another for the reshape). Only one copy of the data and mask # array is made (except for the extra corner). The boolean mask # copy is much smaller than the data array. # An explicit copy of the data array is needed to avoid # modifying the original data array if the shape of the data # array is (y1, x1) (i.e., box_size = data.shape). core = reshape_as_blocks(self._data[:y1, :x1].copy(), self.box_size) core_mask = reshape_as_blocks(mask[:y1, :x1], self.box_size) core = core.reshape((*nboxes, -1)) core_mask = core_mask.reshape((*nboxes, -1)) core[core_mask] = np.nan bkg, bkgrms, ngood = self._compute_box_statistics(core, axis=-1) extra_row = y1 < self._data.shape[0] extra_col = x1 < self._data.shape[1] if self.edge_method == 'pad' and (extra_row or extra_col): if extra_row: # extra row of boxes # here we need to make a copy of the data array to avoid # modifying the original data array # move the axes and combine the last two for performance row_data = self._data[y1:, :x1].copy() row_mask = mask[y1:, :x1] row_data[row_mask] = np.nan row_data = reshape_as_blocks(row_data, (1, self.box_size[1])) row_data = np.moveaxis(row_data, 0, -1) row_data = row_data.reshape((*row_data.shape[:-2], -1)) row_bkg, row_bkgrms, row_ngood = self._compute_box_statistics( row_data, axis=-1) if extra_col: # extra column of boxes # here we need to make a copy of the data array to avoid # modifying the original data array # move the axes and combine the last two for performance col_data = self._data[:y1, x1:].copy() col_mask = mask[:y1, x1:] col_data[col_mask] = np.nan col_data = reshape_as_blocks(col_data, (self.box_size[0], 1)) col_data = np.transpose(col_data, (0, 3, 1, 2)) col_data = col_data.reshape((*col_data.shape[:-2], -1)) col_bkg, col_bkgrms, col_ngood = self._compute_box_statistics( col_data, axis=-1) if extra_row and extra_col: # extra corner box -- append to extra column # here we need to make a copy of the data array to avoid # modifying the original data array corner_data = self._data[y1:, x1:].copy() corner_mask = mask[y1:, x1:] corner_data[corner_mask] = np.nan crn_bkg, crn_bkgrms, crn_ngood = self._compute_box_statistics( corner_data, axis=None) col_bkg = np.vstack((col_bkg, crn_bkg)) col_bkgrms = np.vstack((col_bkgrms, crn_bkgrms)) col_ngood = np.vstack((col_ngood, crn_ngood)) # combine the core and extra boxes to construct the # complete 2D bkg and bkgrms arrays if extra_row: bkg = np.vstack([bkg, row_bkg[:, 0]]) bkgrms = np.vstack([bkgrms, row_bkgrms[:, 0]]) ngood = np.vstack([ngood, row_ngood[:, 0]]) if extra_col: bkg = np.hstack([bkg, col_bkg]) bkgrms = np.hstack([bkgrms, col_bkgrms]) ngood = np.hstack([ngood, col_ngood]) if np.all(np.isnan(bkg)): raise ValueError('All boxes contain <= ' f'{self._good_npixels_threshold} good pixels. ' 'Please check your data or increase ' '"exclude_percentile" to allow more boxes to ' 'be included.') # we no longer need the copy of the input array del self._data return bkg, bkgrms, ngood def _interpolate_grid(self, data, n_neighbors=10, eps=0.0, power=1.0, reg=0.0): """ Fill in any NaN values in the low-resolution 2D mesh background and background RMS images. IDW interpolation is used to replace the NaN pixels. This is required to use a regular-grid interpolator to expand the low-resolution image to the full size image. Parameters ---------- data : 2D `~numpy.ndarray` A 2D array of the box statistics. n_neighbors : int, optional The maximum number of nearest neighbors to use during the interpolation. eps : float, optional Set to use approximate nearest neighbors; the kth neighbor is guaranteed to be no further than (1 + ``eps``) times the distance to the real *k*-th nearest neighbor. See `scipy.spatial.cKDTree.query` for further information. power : float, optional The power of the inverse distance used for the interpolation weights. See the Notes section for more details. reg : float, optional The regularization parameter. It may be used to control the smoothness of the interpolator. See the Notes section for more details. Returns ------- result : 2D `~numpy.ndarray` A 2D array of the box values where NaN values have been filled by IDW interpolation. """ if not np.any(np.isnan(data)): # output integer dtype if input data was integer dtyle if data.dtype != self._data_dtype: data = data.astype(self._data_dtype) 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, reg=reg) interp_data = np.copy(data) # copy to avoid modifying the input data interp_data[idx] = interp_values # output integer dtype if input data was integer dtyle if interp_data.dtype != self._data_dtype: interp_data = interp_data.astype(self._data_dtype) return interp_data def _selective_filter(self, data): """ Filter only pixels above ``filter_threshold`` in a low- resolution 2D image. The pixels to be filtered are determined by applying the ``filter_threshold`` to the low-resolution background mesh. The same pixels are filtered in both the background and background RMS meshes. Parameters ---------- data : 2D `~numpy.ndarray` A 2D array of mesh values. Returns ------- result : 2D `~numpy.ndarray` The filtered 2D array of mesh values. """ data_filtered = np.copy(data) bkg_stats_interp = self._interpolate_grid(self._bkg_stats) yx_indices = np.column_stack( np.nonzero(bkg_stats_interp > self.filter_threshold)) yfs, xfs = self.filter_size hyfs, hxfs = yfs // 2, xfs // 2 for i, j in yx_indices: yidx0 = max(i - hyfs, 0) yidx1 = min(i - hyfs + yfs, data.shape[0]) xidx0 = max(j - hxfs, 0) xidx1 = min(j - hxfs + xfs, data.shape[1]) data_filtered[i, j] = np.median(data[yidx0:yidx1, xidx0:xidx1]) return data_filtered def _filter_grid(self, data): """ Apply a 2D median filter to a low-resolution 2D image. Parameters ---------- data : 2D `~numpy.ndarray` A 2D array of mesh values. Returns ------- result : 2D `~numpy.ndarray` The filtered 2D array of mesh values. """ if tuple(self.filter_size) == (1, 1): return data if (self.filter_threshold is None or self.filter_threshold < self._min_bkg_stats): # filter the entire array filtdata = generic_filter(data, nanmedian, size=self.filter_size, mode='constant', cval=np.nan) else: # selectively filter the array filtdata = self._selective_filter(data) return filtdata def _calculate_mesh_yxcen(self): """ Calculate the y and x positions of the centers of the low- resolution background and background RMS meshes with respect to the input data array. This is used by the IDW interpolator to expand the low- resolution mesh to the full-size image. It is also used to plot the mesh boxes on the input image. """ mesh_idx = np.where(~self._mesh_nan_mask) # good mesh indices box_cen = (self.box_size - 1) / 2.0 return (mesh_idx * self.box_size[:, None]) + box_cen[:, None] @lazyproperty def background_mesh(self): """ The low-resolution background image. This image is equivalent to the low-resolution "MINIBACK" background map check image in SourceExtractor. """ data = self._interpolate_grid(self._bkg_stats) if ('background_rms_mesh' in self.__dict__ or self.filter_threshold is None): self._bkg_stats = None # delete to save memory return self._apply_units(self._filter_grid(data)) @lazyproperty def background_rms_mesh(self): """ The low-resolution background RMS image. This image is equivalent to the low-resolution "MINIBACK_RMS" background rms map check image in SourceExtractor. """ data = self._interpolate_grid(self._bkgrms_stats) self._bkgrms_stats = None # delete to save memory return self._apply_units(self._filter_grid(data)) @property @deprecated('2.0.0') def background_mesh_masked(self): """ The low-resolution background image prior to any interpolation to fill NaN values. The array has NaN values where meshes were excluded. """ data = self.background_mesh.copy() data[self._mesh_nan_mask] = np.nan return data @property @deprecated('2.0.0') def background_rms_mesh_masked(self): """ The low-resolution background RMS image prior to any interpolation to fill NaN values. The array has NaN values where meshes were excluded. """ data = self.background_rms_mesh.copy() data[self._mesh_nan_mask] = np.nan return data @property @deprecated('2.0.0', alternative='npixels_mesh') def mesh_nmasked(self): """ A 2D array of the number of masked pixels in each mesh. NaN values indicate where meshes were excluded. """ data = (np.prod(self.box_size) - self._ngood).astype(float) data[self._mesh_nan_mask] = np.nan return data @property def npixels_mesh(self): """ A 2D array of the number pixels used to compute the statistics in each mesh. """ return self._ngood @property 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 that the returned value is (re)calculated each time this property is accessed. If you need to access the returned image multiple times, you should store the result in a variable. """ npixels_map = block_replicate(self.npixels_mesh, self._interp_kwargs['box_size'], conserve_sum=False) return npixels_map[:self._interp_kwargs['shape'][0], :self._interp_kwargs['shape'][1]] @lazyproperty def background_median(self): """ The median value of the 2D low-resolution background map. This is equivalent to the value SourceExtractor prints to stdout (i.e., "(M+D) Background: "). """ return self._apply_units(np.median(self.background_mesh)) @lazyproperty def background_rms_median(self): """ The median value of the low-resolution background RMS map. This is equivalent to the value SourceExtractor prints to stdout (i.e., "(M+D) RMS: "). """ return self._apply_units(np.median(self.background_rms_mesh)) def _calculate_image(self, data): """ Calculate the full-sized background or background rms image from the low-resolution mesh. """ data = self.interpolator(data, **self._interp_kwargs) if self.coverage_mask is not None: data[self.coverage_mask] = self.fill_value return self._apply_units(data) @property def background(self): """ A 2D `~numpy.ndarray` containing the background image. Note that the returned value is (re)calculated each time this property is accessed. If you need to access the background image multiple times, you should store the result in a variable. """ return self._calculate_image(self.background_mesh) @property def background_rms(self): """ A 2D `~numpy.ndarray` containing the background RMS image. Note that the returned value is (re)calculated each time this property is accessed. If you need to access the background rms image multiple times, you should store the result in a variable. """ return self._calculate_image(self.background_rms_mesh) def plot_meshes(self, *, ax=None, marker='+', markersize=None, color='blue', alpha=None, outlines=False, **kwargs): """ Plot the low-resolution mesh boxes on a matplotlib Axes instance. Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. marker : str, optional The `matplotlib marker `_ to use to mark the center of the boxes. markersize : float, optional The box center marker size in ``points ** 2`` (typographical points are 1/72 inch) . The default is ``matplotlib.rcParams['lines.markersize'] ** 2``. If set to 0, then the box center markers will not be plotted. color : str, optional The color for the box center markers and outlines. alpha : float, optional The alpha blending value, between 0 (transparent) and 1 (opaque), for the box center markers and outlines. outlines : bool, optional Whether or not to plot the box outlines. **kwargs : dict, optional Any keyword arguments accepted by `matplotlib.patches.Patch`, which is used to draw the box outlines. Used only if ``outlines`` is True. """ import matplotlib.pyplot as plt kwargs['color'] = color if ax is None: ax = plt.gca() mesh_xycen = np.flipud(self._calculate_mesh_yxcen()) ax.scatter(*mesh_xycen, s=markersize, marker=marker, color=color, alpha=alpha) if outlines: xycen = np.column_stack(mesh_xycen) apers = RectangularAperture(xycen, w=self.box_size[1], h=self.box_size[0], theta=0.0) apers.plot(ax=ax, alpha=alpha, **kwargs) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/background/core.py0000644000175100001660000005754214755160622020652 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines classes to estimate the background and background RMS in an array of any dimension. """ import abc import warnings import numpy as np from astropy.stats import SigmaClip, mad_std from photutils.extern.biweight import biweight_location, biweight_scale from photutils.utils._repr import make_repr from photutils.utils._stats import nanmean, nanmedian, nanstd SIGMA_CLIP = SigmaClip(sigma=3.0, maxiters=10) __all__ = [ 'BackgroundBase', 'BackgroundRMSBase', 'BiweightLocationBackground', 'BiweightScaleBackgroundRMS', 'MADStdBackgroundRMS', 'MMMBackground', 'MeanBackground', 'MedianBackground', 'ModeEstimatorBackground', 'SExtractorBackground', 'StdBackgroundRMS', ] class BackgroundBase(metaclass=abc.ABCMeta): """ Base class for classes that estimate scalar background values. Parameters ---------- sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. """ def __init__(self, sigma_clip=SIGMA_CLIP): if not isinstance(sigma_clip, SigmaClip) and sigma_clip is not None: raise TypeError('sigma_clip must be an astropy SigmaClip ' 'instance or None') self.sigma_clip = sigma_clip def __repr__(self): return make_repr(self, ('sigma_clip',)) def __call__(self, data, axis=None, masked=False): return self.calc_background(data, axis=axis, masked=masked) @abc.abstractmethod def calc_background(self, data, axis=None, masked=False): """ Calculate the background value. Parameters ---------- data : array_like or `~numpy.ma.MaskedArray` The array for which to calculate the background value. axis : int or `None`, optional The array axis along which the background is calculated. If `None`, then the entire array is used. masked : bool, optional If `True`, then a `~numpy.ma.MaskedArray` is returned. If `False`, then a `~numpy.ndarray` is returned, where masked values have a value of NaN. The default is `False`. Returns ------- result : float, `~numpy.ndarray`, or `~numpy.ma.MaskedArray` The calculated background value. If ``masked`` is `False`, then a `~numpy.ndarray` is returned, otherwise a `~numpy.ma.MaskedArray` is returned. A scalar result is always returned as a float. """ raise NotImplementedError # pragma: no cover class BackgroundRMSBase(metaclass=abc.ABCMeta): """ Base class for classes that estimate scalar background RMS values. Parameters ---------- sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. """ def __init__(self, sigma_clip=SIGMA_CLIP): if not isinstance(sigma_clip, SigmaClip) and sigma_clip is not None: raise TypeError('sigma_clip must be an astropy SigmaClip ' 'instance or None') self.sigma_clip = sigma_clip def __repr__(self): return make_repr(self, ('sigma_clip',)) def __call__(self, data, axis=None, masked=False): return self.calc_background_rms(data, axis=axis, masked=masked) @abc.abstractmethod def calc_background_rms(self, data, axis=None, masked=False): """ Calculate the background RMS value. Parameters ---------- data : array_like or `~numpy.ma.MaskedArray` The array for which to calculate the background RMS value. axis : int or `None`, optional The array axis along which the background RMS is calculated. If `None`, then the entire array is used. masked : bool, optional If `True`, then a `~numpy.ma.MaskedArray` is returned. If `False`, then a `~numpy.ndarray` is returned, where masked values have a value of NaN. The default is `False`. Returns ------- result : float, `~numpy.ndarray`, or `~numpy.ma.MaskedArray` The calculated background RMS value. If ``masked`` is `False`, then a `~numpy.ndarray` is returned, otherwise a `~numpy.ma.MaskedArray` is returned. A scalar result is always returned as a float. """ raise NotImplementedError # pragma: no cover class MeanBackground(BackgroundBase): """ Class to calculate the background in an array as the (sigma-clipped) mean. Parameters ---------- sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import MeanBackground >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkg = MeanBackground(sigma_clip) The background value can be calculated by using the `calc_background` method, e.g.: >>> bkg_value = bkg.calc_background(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 Alternatively, the background value can be calculated by calling the class instance as a function, e.g.: >>> bkg_value = bkg(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 """ def calc_background(self, data, axis=None, masked=False): if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False) elif isinstance(data, np.ma.MaskedArray): data = data.filled(np.nan) # ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = nanmean(data, axis=axis) if masked and isinstance(result, np.ndarray): result = np.ma.masked_where(np.isnan(result), result) return result class MedianBackground(BackgroundBase): """ Class to calculate the background in an array as the (sigma-clipped) median. Parameters ---------- sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import MedianBackground >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkg = MedianBackground(sigma_clip) The background value can be calculated by using the `calc_background` method, e.g.: >>> bkg_value = bkg.calc_background(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 Alternatively, the background value can be calculated by calling the class instance as a function, e.g.: >>> bkg_value = bkg(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 """ def calc_background(self, data, axis=None, masked=False): if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False) elif isinstance(data, np.ma.MaskedArray): # convert to ndarray with masked values replaced by NaN data = data.filled(np.nan) # ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = nanmedian(data, axis=axis) if masked and isinstance(result, np.ndarray): result = np.ma.masked_where(np.isnan(result), result) return result class ModeEstimatorBackground(BackgroundBase): """ Class to calculate the background in an array using a mode estimator of the form ``(median_factor * median) - (mean_factor * mean)``. Parameters ---------- median_factor : float, optional The multiplicative factor for the data median. Defaults to 3. mean_factor : float, optional The multiplicative factor for the data mean. Defaults to 2. sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import ModeEstimatorBackground >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkg = ModeEstimatorBackground(median_factor=3.0, mean_factor=2.0, ... sigma_clip=sigma_clip) The background value can be calculated by using the `calc_background` method, e.g.: >>> bkg_value = bkg.calc_background(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 Alternatively, the background value can be calculated by calling the class instance as a function, e.g.: >>> bkg_value = bkg(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 """ def __init__(self, median_factor=3.0, mean_factor=2.0, 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) def calc_background(self, data, axis=None, masked=False): if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False) elif isinstance(data, np.ma.MaskedArray): # convert to ndarray with masked values replaced by NaN data = data.filled(np.nan) # ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = ((self.median_factor * nanmedian(data, axis=axis)) - (self.mean_factor * nanmean(data, axis=axis))) if masked and isinstance(result, np.ndarray): result = np.ma.masked_where(np.isnan(result), result) return result class MMMBackground(ModeEstimatorBackground): """ Class to calculate the background in an array using the DAOPHOT MMM algorithm. The background is calculated using a mode estimator of the form ``(3 * median) - (2 * mean)``. Parameters ---------- sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import MMMBackground >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkg = MMMBackground(sigma_clip=sigma_clip) The background value can be calculated by using the `~photutils.background.core.ModeEstimatorBackground.calc_background` method, e.g.: >>> bkg_value = bkg.calc_background(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 Alternatively, the background value can be calculated by calling the class instance as a function, e.g.: >>> bkg_value = bkg(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 """ def __init__(self, sigma_clip=SIGMA_CLIP): super().__init__(median_factor=3.0, mean_factor=2.0, sigma_clip=sigma_clip) class SExtractorBackground(BackgroundBase): """ Class to calculate the background in an array using the Source Extractor algorithm. The background is calculated using a mode estimator of the form ``(2.5 * median) - (1.5 * mean)``. If ``(mean - median) / std > 0.3`` then the median is used instead. .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ Parameters ---------- sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import SExtractorBackground >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkg = SExtractorBackground(sigma_clip) The background value can be calculated by using the `calc_background` method, e.g.: >>> bkg_value = bkg.calc_background(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 Alternatively, the background value can be calculated by calling the class instance as a function, e.g.: >>> bkg_value = bkg(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 """ def calc_background(self, data, axis=None, masked=False): if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False) elif isinstance(data, np.ma.MaskedArray): # convert to ndarray with masked values replaced by NaN data = data.filled(np.nan) # ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) _median = np.atleast_1d(nanmedian(data, axis=axis)) _mean = np.atleast_1d(nanmean(data, axis=axis)) _std = np.atleast_1d(nanstd(data, axis=axis)) bkg = (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 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 bkg is a scalar, return it as a float if bkg.shape == (1,) and axis is None: bkg = bkg[0] if masked and isinstance(bkg, np.ndarray): bkg = np.ma.masked_where(np.isnan(bkg), bkg) return bkg class BiweightLocationBackground(BackgroundBase): """ Class to calculate the background in an array using the biweight location. Parameters ---------- c : float, optional Tuning constant for the biweight estimator. Default value is 6.0. M : float, optional Initial guess for the biweight location. Default value is `None`. sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import BiweightLocationBackground >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkg = BiweightLocationBackground(sigma_clip=sigma_clip) The background value can be calculated by using the `calc_background` method, e.g.: >>> bkg_value = bkg.calc_background(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 Alternatively, the background value can be calculated by calling the class instance as a function, e.g.: >>> bkg_value = bkg(data) >>> print(bkg_value) # doctest: +FLOAT_CMP 49.5 """ def __init__(self, c=6, M=None, 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) def calc_background(self, data, axis=None, masked=False): if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False) elif isinstance(data, np.ma.MaskedArray): # convert to ndarray with masked values replaced by NaN data = data.filled(np.nan) # ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = biweight_location(data, c=self.c, M=self.M, axis=axis, ignore_nan=True) if masked and isinstance(result, np.ndarray): result = np.ma.masked_where(np.isnan(result), result) return result class StdBackgroundRMS(BackgroundRMSBase): """ Class to calculate the background RMS in an array as the (sigma- clipped) standard deviation. Parameters ---------- sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import StdBackgroundRMS >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkgrms = StdBackgroundRMS(sigma_clip) The background RMS value can be calculated by using the `calc_background_rms` method, e.g.: >>> bkgrms_value = bkgrms.calc_background_rms(data) >>> print(bkgrms_value) # doctest: +FLOAT_CMP 28.86607004772212 Alternatively, the background RMS value can be calculated by calling the class instance as a function, e.g.: >>> bkgrms_value = bkgrms(data) >>> print(bkgrms_value) # doctest: +FLOAT_CMP 28.86607004772212 """ def calc_background_rms(self, data, axis=None, masked=False): if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False) elif isinstance(data, np.ma.MaskedArray): # convert to ndarray with masked values replaced by NaN data = data.filled(np.nan) # ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = nanstd(data, axis=axis) if masked and isinstance(result, np.ndarray): result = np.ma.masked_where(np.isnan(result), result) return result class MADStdBackgroundRMS(BackgroundRMSBase): r""" Class to calculate the background RMS in an array as using the `median absolute deviation (MAD) `_. The standard deviation estimator is given by: .. math:: \sigma \approx \frac{{\textrm{{MAD}}}}{{\Phi^{{-1}}(3/4)}} \approx 1.4826 \ \textrm{{MAD}} where :math:`\Phi^{{-1}}(P)` is the normal inverse cumulative distribution function evaluated at probability :math:`P = 3/4`. Parameters ---------- sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import MADStdBackgroundRMS >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkgrms = MADStdBackgroundRMS(sigma_clip) The background RMS value can be calculated by using the `calc_background_rms` method, e.g.: >>> bkgrms_value = bkgrms.calc_background_rms(data) >>> print(bkgrms_value) # doctest: +FLOAT_CMP 37.06505546264005 Alternatively, the background RMS value can be calculated by calling the class instance as a function, e.g.: >>> bkgrms_value = bkgrms(data) >>> print(bkgrms_value) # doctest: +FLOAT_CMP 37.06505546264005 """ def calc_background_rms(self, data, axis=None, masked=False): if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False) elif isinstance(data, np.ma.MaskedArray): # convert to ndarray with masked values replaced by NaN data = data.filled(np.nan) # ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = mad_std(data, axis=axis, ignore_nan=True) if masked and isinstance(result, np.ndarray): result = np.ma.masked_where(np.isnan(result), result) return result class BiweightScaleBackgroundRMS(BackgroundRMSBase): """ Class to calculate the background RMS in an array as the (sigma- clipped) biweight scale. Parameters ---------- c : float, optional Tuning constant for the biweight estimator. Default value is 9.0. M : float, optional Initial guess for the biweight location. Default value is `None`. sigma_clip : `astropy.stats.SigmaClip` object, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. If `None` then no sigma clipping will be performed. The default is to perform sigma clipping with ``sigma=3.0`` and ``maxiters=5``. Examples -------- >>> from astropy.stats import SigmaClip >>> from photutils.background import BiweightScaleBackgroundRMS >>> data = np.arange(100) >>> sigma_clip = SigmaClip(sigma=3.0) >>> bkgrms = BiweightScaleBackgroundRMS(sigma_clip=sigma_clip) The background RMS value can be calculated by using the `calc_background_rms` method, e.g.: >>> bkgrms_value = bkgrms.calc_background_rms(data) >>> print(bkgrms_value) # doctest: +FLOAT_CMP 30.09433848589339 Alternatively, the background RMS value can be calculated by calling the class instance as a function, e.g.: >>> bkgrms_value = bkgrms(data) >>> print(bkgrms_value) # doctest: +FLOAT_CMP 30.09433848589339 """ def __init__(self, c=9.0, M=None, 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) def calc_background_rms(self, data, axis=None, masked=False): if self.sigma_clip is not None: data = self.sigma_clip(data, axis=axis, masked=False) elif isinstance(data, np.ma.MaskedArray): # convert to ndarray with masked values replaced by NaN data = data.filled(np.nan) # ignore RuntimeWarning where axis is all NaN with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) result = biweight_scale(data, c=self.c, M=self.M, axis=axis, ignore_nan=True) if masked and isinstance(result, np.ndarray): result = np.ma.masked_where(np.isnan(result), result) return result ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/background/interpolators.py0000644000175100001660000001540614755160622022620 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines interpolator classes for Background2D. """ import numpy as np from astropy.units import Quantity from astropy.utils.decorators import deprecated_renamed_argument from scipy.ndimage import zoom from photutils.utils import ShepardIDWInterpolator from photutils.utils._repr import make_repr __all__ = ['BkgIDWInterpolator', 'BkgZoomInterpolator'] class BkgZoomInterpolator: """ This class generates full-sized background and background RMS images from lower-resolution mesh images using the `~scipy.ndimage.zoom` (spline) interpolator. This class must be used in concert with the `Background2D` class. Parameters ---------- order : int, optional The order of the spline interpolation used to resize the low-resolution background and background RMS mesh images. The value must be an integer in the range 0-5. The default is 3 (bicubic interpolation). mode : {'reflect', 'constant', 'nearest', 'wrap'}, optional Points outside the boundaries of the input are filled according to the given mode. Default is 'reflect'. cval : float, optional The value used for points outside the boundaries of the input if ``mode='constant'``. Default is 0.0. 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. grid_mode : bool, optional If `True` (default), the samples are considered as the centers of regularly-spaced grid elements. If `False`, the samples are treated as isolated points. For zooming 2D images, this keyword should be set to `True`, which makes zoom's behavior consistent with `scipy.ndimage.map_coordinates` and `skimage.transform.resize`. The `False` option is provided only for backwards-compatibility. .. deprecated:: 2.0.0 When this keyword is removed, the behavior will be ``grid_mode=True``. """ @deprecated_renamed_argument('grid_mode', None, '2.0.0') def __init__(self, *, order=3, mode='reflect', cval=0.0, clip=True, grid_mode=True): self.order = order self.mode = mode self.cval = cval self.grid_mode = grid_mode self.clip = clip def __repr__(self): params = ('order', 'mode', 'cval', 'clip', 'grid_mode') return make_repr(self, params) def __call__(self, data, **kwargs): """ Resize the 2D mesh array. Parameters ---------- data : 2D `~numpy.ndarray` The low-resolution 2D mesh array. **kwargs : dict Additional keyword arguments passed to the interpolator. Returns ------- result : 2D `~numpy.ndarray` The resized background or background RMS image. """ 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']) if kwargs['edge_method'] == 'pad': # The mesh is first resized to the larger padded-data size # (i.e., zoom_factor should be an integer) and then cropped # back to the final data size. zoom_factor = kwargs['box_size'] result = zoom(data, zoom_factor, order=self.order, mode=self.mode, cval=self.cval, grid_mode=self.grid_mode) result = result[0:kwargs['shape'][0], 0:kwargs['shape'][1]] else: # The mesh is resized directly to the final data size. zoom_factor = np.array(kwargs['shape']) / data.shape result = zoom(data, zoom_factor, order=self.order, mode=self.mode, cval=self.cval) if self.clip: minval = np.min(data) maxval = np.max(data) np.clip(result, minval, maxval, out=result) # clip in place return result class BkgIDWInterpolator: """ This class generates full-sized background and background RMS images from lower-resolution mesh images using inverse-distance weighting (IDW) interpolation (`~photutils.utils.ShepardIDWInterpolator`). This class must be used in concert with the `Background2D` class. Parameters ---------- leafsize : float, optional The number of points at which the k-d tree algorithm switches over to brute-force. ``leafsize`` must be positive. See `scipy.spatial.cKDTree` for further information. n_neighbors : int, optional The maximum number of nearest neighbors to use during the interpolation. power : float, optional The power of the inverse distance used for the interpolation weights. reg : float, optional The regularization parameter. It may be used to control the smoothness of the interpolator. """ def __init__(self, *, leafsize=10, n_neighbors=10, power=1.0, reg=0.0): self.leafsize = leafsize self.n_neighbors = n_neighbors self.power = power self.reg = reg def __repr__(self): params = ('leafsize', 'n_neighbors', 'power', 'reg') return make_repr(self, params) def __call__(self, data, **kwargs): """ Resize the 2D mesh array. Parameters ---------- data : 2D `~numpy.ndarray` The low-resolution 2D mesh array. **kwargs : dict Additional keyword arguments passed to the interpolator. Returns ------- result : 2D `~numpy.ndarray` The resized background or background RMS image. """ 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']) # we 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) # 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, reg=self.reg) return data.reshape(kwargs['shape']) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/background/local_background.py0000644000175100001660000000547114755160622023205 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines classes to estimate local background using a circular annulus aperture. """ import numpy as np from photutils.aperture import CircularAnnulus from photutils.background import MedianBackground from photutils.utils._repr import make_repr __all__ = ['LocalBackground'] class LocalBackground: """ Class to compute a local background using a circular annulus aperture. Parameters ---------- inner_radius : float The inner radius of the circular annulus in pixels. outer_radius : float The outer radius of the circular annulus in pixels. bkg_estimator : callable, optional A callable object (a function or e.g., an instance of any `~photutils.background.BackgroundBase` subclass) used to estimate the background in each aperture. The callable object must take in a 2D `~numpy.ndarray` or `~numpy.ma.MaskedArray` and have an ``axis`` keyword. The default is an instance of `~photutils.background.MedianBackground` with sigma clipping (i.e., sigma-clipped median). """ def __init__(self, inner_radius, outer_radius, bkg_estimator=MedianBackground()): self.inner_radius = inner_radius self.outer_radius = outer_radius self.bkg_estimator = bkg_estimator self._aperture = CircularAnnulus((0, 0), inner_radius, outer_radius) def __repr__(self): params = ('inner_radius', 'outer_radius', 'bkg_estimator') return make_repr(self, params) def __call__(self, data, x, y, mask=None): """ Measure the local background in a circular annulus. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array on which to measure the local background. x, y : float or 1D float `~numpy.ndarray` The aperture center (x, y) position(s) at which to measure the local background. mask : 2D bool `~numpy.ndarray`, optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. Returns ------- value : float or 1D float `~numpy.ndarray` The local background values. """ x = np.atleast_1d(x) y = np.atleast_1d(y) self._aperture.positions = np.array(list(zip(x, y, strict=True))) apermasks = self._aperture.to_mask(method='center') bkg = [] for apermask in apermasks: values = apermask.get_values(data, mask=mask) bkg.append(self.bkg_estimator(values)) bkg = np.array(bkg) if bkg.size == 1: bkg = bkg[0] return bkg ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6949267 photutils-2.2.0/photutils/background/tests/0000755000175100001660000000000014755160634020500 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/background/tests/__init__.py0000644000175100001660000000000014755160622022574 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/background/tests/test_background_2d.py0000644000175100001660000005145114755160622024620 0ustar00runnerdocker# 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.utils.exceptions import (AstropyDeprecationWarning, AstropyUserWarning) from numpy.testing import assert_allclose, assert_equal from photutils.background.background_2d import Background2D from photutils.background.core import MeanBackground, SExtractorBackground from photutils.background.interpolators import (BkgIDWInterpolator, BkgZoomInterpolator) from photutils.utils._optional_deps import HAS_MATPLOTLIB DATA = np.ones((100, 100)) BKG_RMS = np.zeros((100, 100)) BKG_MESH = np.ones((4, 4)) BKG_RMS_MESH = np.zeros((4, 4)) PADBKG_MESH = np.ones((5, 5)) PADBKG_RMS_MESH = np.zeros((5, 5)) FILTER_SIZES = [(1, 1), (3, 3)] INTERPOLATORS = [BkgZoomInterpolator(), BkgIDWInterpolator()] DATA1 = DATA << u.ct DATA2 = NDData(DATA, unit=None) DATA3 = NDData(DATA, unit=u.ct) DATA4 = CCDData(DATA, unit=u.ct) class TestBackground2D: @pytest.mark.parametrize('filter_size', FILTER_SIZES) @pytest.mark.parametrize('interpolator', INTERPOLATORS) def test_background(self, filter_size, interpolator): bkg = Background2D(DATA, (25, 25), filter_size=filter_size, interpolator=interpolator) assert_allclose(bkg.background, DATA) assert_allclose(bkg.background_rms, BKG_RMS) assert_allclose(bkg.background_mesh, BKG_MESH) assert_allclose(bkg.background_rms_mesh, BKG_RMS_MESH) assert bkg.background_median == 1.0 assert bkg.background_rms_median == 0.0 assert bkg.npixels_mesh.shape == (4, 4) assert bkg.npixels_map.shape == 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): filter_size = 3 interpolator = BkgZoomInterpolator() data2 = DATA.copy().astype(dtype) bkg = Background2D(data2, box_size, filter_size=filter_size, interpolator=interpolator) 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 assert bkg.npixels_map.dtype == int assert bkg.npixels_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.npixels_map.shape == DATA.shape @pytest.mark.parametrize('data', [DATA1, DATA3, DATA4]) def test_background_nddata(self, data): """ Test with NDData and CCDData, and also test units. """ bkg = Background2D(data, (25, 25), filter_size=3) assert isinstance(bkg.background, u.Quantity) assert isinstance(bkg.background_rms, u.Quantity) assert isinstance(bkg.background_median, u.Quantity) assert isinstance(bkg.background_rms_median, u.Quantity) bkg = Background2D(DATA2, (25, 25), filter_size=3) assert_allclose(bkg.background, DATA) assert_allclose(bkg.background_rms, BKG_RMS) assert_allclose(bkg.background_mesh, BKG_MESH) assert_allclose(bkg.background_rms_mesh, BKG_RMS_MESH) assert bkg.background_median == 1.0 assert bkg.background_rms_median == 0.0 @pytest.mark.parametrize('interpolator', INTERPOLATORS) def test_background_rect(self, interpolator): """ Regression test for interpolators with non-square input data. """ data = np.arange(12).reshape(3, 4) rms = np.zeros((3, 4)) bkg = Background2D(data, (1, 1), filter_size=1, interpolator=interpolator) assert_allclose(bkg.background, data, atol=0.005) assert_allclose(bkg.background_rms, rms) assert_allclose(bkg.background_mesh, data) assert_allclose(bkg.background_rms_mesh, rms) assert bkg.background_median == 5.5 assert bkg.background_rms_median == 0.0 @pytest.mark.parametrize('interpolator', INTERPOLATORS) def test_background_nonconstant(self, interpolator): data = np.copy(DATA) data[25:50, 50:75] = 10.0 bkg_low_res = np.copy(BKG_MESH) bkg_low_res[1, 2] = 10.0 bkg1 = Background2D(data, (25, 25), filter_size=(1, 1), interpolator=interpolator) assert_allclose(bkg1.background_mesh, bkg_low_res) assert bkg1.background.shape == data.shape bkg2 = Background2D(data, (25, 25), filter_size=(1, 1), interpolator=interpolator) assert_allclose(bkg2.background_mesh, bkg_low_res) assert bkg2.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, interpolator=interpolator) 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.npixels_mesh.shape == (5, 12) assert bkg.npixels_map.shape == data.shape def test_no_sigma_clipping(self): data = np.copy(DATA) data[10, 10] = 100.0 bkg1 = Background2D(data, (25, 25), filter_size=(1, 1), bkg_estimator=MeanBackground()) bkg2 = Background2D(data, (25, 25), filter_size=(1, 1), sigma_clip=None, bkg_estimator=MeanBackground()) assert bkg2.background_mesh[0, 0] > bkg1.background_mesh[0, 0] @pytest.mark.parametrize('filter_size', FILTER_SIZES) def test_resizing(self, filter_size): with pytest.warns(AstropyDeprecationWarning): bkg1 = Background2D(DATA, (23, 22), filter_size=filter_size, bkg_estimator=MeanBackground(), edge_method='crop') bkg2 = Background2D(DATA, (23, 22), filter_size=filter_size, bkg_estimator=MeanBackground()) assert_allclose(bkg1.background, bkg2.background, rtol=2e-6) assert_allclose(bkg1.background_rms, bkg2.background_rms) shape1 = (128, 256) shape2 = (129, 256) box_size = (16, 16) data1 = np.ones(shape1) data2 = np.ones(shape2) bkg1 = Background2D(data1, box_size) bkg2 = Background2D(data2, box_size) assert bkg1.background_mesh.shape == (8, 16) assert bkg2.background_mesh.shape == (9, 16) assert bkg1.background.shape == shape1 assert bkg2.background.shape == shape2 @pytest.mark.parametrize('box_size', ([(25, 25), (23, 22)])) def test_background_mask(self, box_size): """ Test with an input mask. Note that box_size=(23, 22) tests the resizing of the image and mask. """ data = np.copy(DATA) data[25:50, 25:50] = 100.0 mask = np.zeros(DATA.shape, dtype=bool) mask[25:50, 25:50] = True bkg = Background2D(data, box_size, filter_size=(1, 1), mask=mask, bkg_estimator=MeanBackground()) assert_allclose(bkg.background, DATA, rtol=2.0e-5) assert_allclose(bkg.background_rms, BKG_RMS) # test edge crop with mask with pytest.warns(AstropyDeprecationWarning): bkg2 = Background2D(data, box_size, filter_size=(1, 1), mask=mask, bkg_estimator=MeanBackground(), edge_method='crop') assert_allclose(bkg2.background, DATA, rtol=2.0e-5) def test_mask(self): data = np.copy(DATA) data[25:50, 25:50] = 100.0 mask = np.zeros(DATA.shape, dtype=bool) mask[25:50, 25:50] = True bkg1 = Background2D(data, (25, 25), filter_size=(1, 1), mask=None, bkg_estimator=MeanBackground()) with pytest.warns(AstropyDeprecationWarning): assert_equal(bkg1.background_mesh, bkg1.background_mesh_masked) with pytest.warns(AstropyDeprecationWarning): assert_equal(bkg1.background_rms_mesh, bkg1.background_rms_mesh_masked) with pytest.warns(AstropyDeprecationWarning): assert np.count_nonzero(np.isnan(bkg1.mesh_nmasked)) == 0 bkg2 = Background2D(data, (25, 25), filter_size=(1, 1), mask=mask, bkg_estimator=MeanBackground()) nboxes_tot = 25 * 25 with pytest.warns(AstropyDeprecationWarning): assert (np.count_nonzero(~np.isnan(bkg2.background_mesh_masked)) < nboxes_tot) with pytest.warns(AstropyDeprecationWarning): assert (np.count_nonzero(~np.isnan( bkg2.background_rms_mesh_masked)) < nboxes_tot) with pytest.warns(AstropyDeprecationWarning): assert np.count_nonzero(np.isnan(bkg2.mesh_nmasked)) == 1 @pytest.mark.parametrize('fill_value', [0.0, np.nan, -1.0]) def test_coverage_mask(self, fill_value): data = np.copy(DATA) data[:50, :50] = np.nan mask = np.isnan(data) bkg1 = Background2D(data, (25, 25), filter_size=(1, 1), coverage_mask=mask, fill_value=fill_value, bkg_estimator=MeanBackground()) assert_equal(bkg1.background[:50, :50], fill_value) assert_equal(bkg1.background_rms[:50, :50], fill_value) # test combination of masks mask = np.zeros(DATA.shape, dtype=bool) coverage_mask = np.zeros(DATA.shape, dtype=bool) mask[:50, :25] = True coverage_mask[:50, 25:50] = True match = 'Input data contains invalid values' with pytest.warns(AstropyUserWarning, match=match): bkg2 = Background2D(data, (25, 25), filter_size=(1, 1), mask=mask, coverage_mask=mask, fill_value=0.0, bkg_estimator=MeanBackground()) assert_allclose(bkg1.background_mesh, bkg2.background_mesh) assert_allclose(bkg1.background_rms_mesh, bkg2.background_rms_mesh) def test_mask_nonfinite(self): data = DATA.copy() data[0, 0:50] = np.nan match = 'Input data contains invalid values' with pytest.warns(AstropyUserWarning, match=match): bkg = Background2D(data, (25, 25), filter_size=(1, 1)) assert_allclose(bkg.background, DATA, rtol=1e-5) def test_mask_with_already_masked_nans(self): """ Test masked invalid values. These tests should not issue a warning. """ data = DATA.copy() data[50, 25:50] = np.nan mask = np.isnan(data) bkg = Background2D(data, (25, 25), filter_size=(1, 1), mask=mask) assert_allclose(bkg.background, 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): data = DATA.copy() data[0, 0:50] = True mask = np.zeros(DATA.shape, dtype=bool) mask[0, 0:50] = True data_ma1 = np.ma.MaskedArray(DATA, mask=mask) data_ma2 = np.ma.MaskedArray(data, mask=mask) bkg1 = Background2D(data, (25, 25), filter_size=(1, 1)) bkg2 = Background2D(data_ma1, (25, 25), filter_size=(1, 1)) bkg3 = Background2D(data_ma2, (25, 25), filter_size=(1, 1)) assert_allclose(bkg1.background, bkg2.background, rtol=1e-5) assert_allclose(bkg2.background, bkg3.background, rtol=1e-5) def test_completely_masked(self): mask = np.ones(DATA.shape, dtype=bool) match = 'All boxes contain' with pytest.raises(ValueError, match=match): Background2D(DATA, (25, 25), mask=mask) def test_zero_padding(self): """ Test case where padding is added only on one axis. """ bkg = Background2D(DATA, (25, 22), filter_size=(1, 1)) assert_allclose(bkg.background, DATA, rtol=1e-5) assert_allclose(bkg.background_rms, BKG_RMS) assert bkg.background_median == 1.0 assert bkg.background_rms_median == 0.0 bkg = Background2D(DATA, (22, 25), filter_size=(1, 1)) assert_allclose(bkg.background, DATA, rtol=1e-5) assert_allclose(bkg.background_rms, BKG_RMS) assert bkg.background_median == 1.0 assert bkg.background_rms_median == 0.0 def test_exclude_percentile(self): """ Only meshes greater than filter_threshold are filtered. """ data = np.copy(DATA) data[0:50, 0:50] = np.nan match = 'Input data contains invalid values' with pytest.warns(AstropyUserWarning, match=match): bkg = Background2D(data, (25, 25), filter_size=(1, 1), exclude_percentile=100.0) assert_equal(bkg.npixels_mesh[0:2, 0:2], np.zeros((2, 2))) assert bkg.npixels_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))) def test_filter_threshold(self): """ Only meshes greater than filter_threshold are filtered. """ data = np.copy(DATA) data[25:50, 50:75] = 10.0 bkg = Background2D(data, (25, 25), filter_size=(3, 3), filter_threshold=9.0) assert_allclose(bkg.background, DATA) assert_allclose(bkg.background_mesh, BKG_MESH) bkg2 = Background2D(data, (25, 25), filter_size=(3, 3), filter_threshold=11.0) # no filtering assert bkg2.background_mesh[1, 2] == 10 def test_filter_threshold_high(self): """ No filtering because filter_threshold is too large. """ data = np.copy(DATA) data[25:50, 50:75] = 10.0 ref_data = np.copy(BKG_MESH) ref_data[1, 2] = 10.0 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): """ No filtering because filter_size is (1, 1). """ data = np.copy(DATA) data[25:50, 50:75] = 10.0 ref_data = np.copy(BKG_MESH) ref_data[1, 2] = 10.0 b = Background2D(data, (25, 25), filter_size=(1, 1), filter_threshold=1.0) assert_allclose(b.background_mesh, ref_data) def test_scalar_sizes(self): bkg1 = Background2D(DATA, (25, 25), filter_size=(3, 3)) bkg2 = Background2D(DATA, 25, filter_size=3) assert_allclose(bkg1.background, bkg2.background) assert_allclose(bkg1.background_rms, bkg2.background_rms) def test_invalid_box_size(self): match = 'box_size must have 1 or 2 elements' with pytest.raises(ValueError, match=match): Background2D(DATA, (5, 5, 3)) def test_invalid_filter_size(self): match = 'filter_size must have 1 or 2 elements' with pytest.raises(ValueError, match=match): Background2D(DATA, (5, 5), filter_size=(3, 3, 3)) def test_invalid_exclude_percentile(self): match = 'exclude_percentile must be between 0 and 100' with pytest.raises(ValueError, match=match): Background2D(DATA, (5, 5), exclude_percentile=-1) with pytest.raises(ValueError, match=match): Background2D(DATA, (5, 5), exclude_percentile=101) def test_mask_nomask(self): bkg = Background2D(DATA, (25, 25), filter_size=(1, 1), mask=np.ma.nomask) assert bkg._mask is None bkg = Background2D(DATA, (25, 25), filter_size=(1, 1), coverage_mask=np.ma.nomask) assert bkg.coverage_mask is None def test_invalid_mask(self): match = 'data and mask must have the same shape' with pytest.raises(ValueError, match=match): Background2D(DATA, (25, 25), filter_size=(1, 1), mask=np.zeros((2, 2))) match = 'mask must be a 2D array' with pytest.raises(ValueError, match=match): Background2D(DATA, (25, 25), filter_size=(1, 1), mask=np.zeros((2, 2, 2))) def test_invalid_coverage_mask(self): match = 'data and coverage_mask must have the same shape' with pytest.raises(ValueError, match=match): Background2D(DATA, (25, 25), filter_size=(1, 1), coverage_mask=np.zeros((2, 2))) match = 'coverage_mask must be a 2D array' with pytest.raises(ValueError, match=match): Background2D(DATA, (25, 25), filter_size=(1, 1), coverage_mask=np.zeros((2, 2, 2))) def test_invalid_edge_method(self): match = 'edge_method must be "pad" or "crop"' with (pytest.warns(AstropyDeprecationWarning), pytest.raises(ValueError, match=match)): Background2D(DATA, (23, 22), filter_size=(1, 1), edge_method='not_valid') @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_plot_meshes(self): """ This test should run without any errors, but there is no return value. """ bkg = Background2D(DATA, (25, 25)) bkg.plot_meshes(outlines=True) def test_crop(self): data = np.ones((300, 500)) with pytest.warns(AstropyDeprecationWarning): bkg = Background2D(data, (74, 99), edge_method='crop') assert_allclose(bkg.background_median, 1.0) assert_allclose(bkg.background_rms_median, 0.0) assert_allclose(bkg.background_mesh.shape, (4, 5)) def test_repr(self): data = np.ones((300, 500)) bkg = Background2D(data, (74, 99)) cls_repr = repr(bkg) assert cls_repr.startswith(f'{bkg.__class__.__name__}') def test_str(self): 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): arr = np.arange(25.0).reshape(5, 5) arr_orig = arr.copy() mask = np.zeros(arr.shape, dtype=bool) mask[0, 0] = np.nan mask[-1, 0] = np.nan mask[-1, -1] = np.nan mask[0, -1] = np.nan box_size = (2, 2) exclude_percentile = 100 filter_size = 1 bkg_estimator = MeanBackground() bkg1 = Background2D(arr, box_size, mask=mask, exclude_percentile=exclude_percentile, filter_size=filter_size, bkg_estimator=bkg_estimator) bkgimg1 = bkg1.background assert_equal(arr, arr_orig) arr2 = arr.copy() arr2[mask] = np.nan arr3 = arr2.copy() match = 'Input data contains invalid 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): """ Regression test to ensure that when boxsize is the same as the image size that the input data left unchanged. """ 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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/background/tests/test_core.py0000644000175100001660000002357214755160622023047 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the core module. """ import astropy.units as u import numpy as np import pytest from astropy.stats import SigmaClip from numpy.testing import assert_allclose from photutils.background.core import (BiweightLocationBackground, BiweightScaleBackgroundRMS, MADStdBackgroundRMS, MeanBackground, MedianBackground, MMMBackground, ModeEstimatorBackground, SExtractorBackground, StdBackgroundRMS) from photutils.datasets import make_noise_image from photutils.utils._stats import nanmean BKG = 0.0 STD = 0.5 DATA = make_noise_image((100, 100), distribution='gaussian', mean=BKG, stddev=STD, seed=0) BKG_CLASS = [MeanBackground, MedianBackground, ModeEstimatorBackground, MMMBackground, SExtractorBackground, BiweightLocationBackground] RMS_CLASS = [StdBackgroundRMS, MADStdBackgroundRMS, BiweightScaleBackgroundRMS] SIGMA_CLIP = SigmaClip(sigma=3.0) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_constant_background(bkg_class): data = np.ones((100, 100)) bkg = bkg_class(sigma_clip=SIGMA_CLIP) bkgval = bkg.calc_background(data) assert not np.ma.isMaskedArray(bkgval) assert_allclose(bkgval, 1.0) assert_allclose(bkg(data), bkg.calc_background(data)) mask = np.zeros(data.shape, dtype=bool) mask[0, 0:10] = True data = np.ma.MaskedArray(data, mask=mask) bkgval = bkg.calc_background(data) assert not np.ma.isMaskedArray(bkgval) assert_allclose(bkgval, 1.0) assert_allclose(bkg(data), bkg.calc_background(data)) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_background(bkg_class): bkg = bkg_class(sigma_clip=SIGMA_CLIP) bkgval = bkg.calc_background(DATA) assert not np.ma.isMaskedArray(bkgval) assert_allclose(bkgval, BKG, atol=0.02) assert_allclose(bkg(DATA), bkg.calc_background(DATA)) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_background_nosigmaclip(bkg_class): bkg = bkg_class(sigma_clip=None) bkgval = bkg.calc_background(DATA) assert not np.ma.isMaskedArray(bkgval) assert_allclose(bkgval, BKG, atol=0.1) assert_allclose(bkg(DATA), bkg.calc_background(DATA)) # test with masked array mask = np.zeros(DATA.shape, dtype=bool) mask[0, 0:10] = True data = np.ma.MaskedArray(DATA, mask=mask) bkgval = bkg.calc_background(data) assert not np.ma.isMaskedArray(bkgval) assert_allclose(bkgval, BKG, atol=0.1) assert_allclose(bkg(data), bkg.calc_background(data)) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_background_axis(bkg_class): bkg = bkg_class(sigma_clip=SIGMA_CLIP) bkg_arr = bkg.calc_background(DATA, axis=0) bkgi = np.array([bkg.calc_background(DATA[:, i]) for i in range(100)]) assert_allclose(bkg_arr, bkgi) bkg_arr = bkg.calc_background(DATA, axis=1) bkgi = [] for i in range(100): bkgi.append(bkg.calc_background(DATA[i, :])) bkgi = np.array(bkgi) assert_allclose(bkg_arr, bkgi) def test_sourceextrator_background_zero_std(): data = np.ones((100, 100)) bkg = SExtractorBackground(sigma_clip=None) assert_allclose(bkg.calc_background(data), 1.0) def test_sourceextrator_background_skew(): data = np.arange(100) data[70:] = 1.0e7 bkg = SExtractorBackground(sigma_clip=None) assert_allclose(bkg.calc_background(data), np.median(data)) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_background_ndim(bkg_class): 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('bkgrms_class', RMS_CLASS) def test_background_rms_ndim(bkgrms_class): 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 = bkgrms_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', RMS_CLASS) def test_background_rms(rms_class): bkgrms = rms_class(sigma_clip=SIGMA_CLIP) assert_allclose(bkgrms.calc_background_rms(DATA), STD, atol=1.0e-2) assert_allclose(bkgrms(DATA), bkgrms.calc_background_rms(DATA)) @pytest.mark.parametrize('rms_class', RMS_CLASS) def test_background_rms_axis(rms_class): bkgrms = rms_class(sigma_clip=SIGMA_CLIP) rms_arr = bkgrms.calc_background_rms(DATA, axis=0) rmsi = np.array([bkgrms.calc_background_rms(DATA[:, i]) for i in range(100)]) assert_allclose(rms_arr, rmsi) rms_arr = bkgrms.calc_background_rms(DATA, axis=1) rmsi = [] for i in range(100): rmsi.append(bkgrms.calc_background_rms(DATA[i, :])) rmsi = np.array(rmsi) assert_allclose(rms_arr, rmsi) @pytest.mark.parametrize('rms_class', RMS_CLASS) def test_background_rms_nosigmaclip(rms_class): bkgrms = rms_class(sigma_clip=None) assert_allclose(bkgrms.calc_background_rms(DATA), STD, atol=1.0e-2) assert_allclose(bkgrms(DATA), bkgrms.calc_background_rms(DATA)) # test with masked array mask = np.zeros(DATA.shape, dtype=bool) mask[0, 0:10] = True data = np.ma.MaskedArray(DATA, mask=mask) rms = bkgrms.calc_background_rms(data) assert not np.ma.isMaskedArray(bkgrms) assert_allclose(rms, STD, atol=0.01) assert_allclose(bkgrms(data), bkgrms.calc_background_rms(data)) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_background_masked(bkg_class): bkg = bkg_class(sigma_clip=None) mask = np.zeros(DATA.shape, dtype=bool) mask[0, 0:10] = True data = np.ma.MaskedArray(DATA, mask=mask) # test masked array with masked=True with axis bkgval1 = bkg(data, masked=True, axis=1) bkgval2 = bkg.calc_background(data, masked=True, axis=1) assert np.ma.isMaskedArray(bkgval1) assert_allclose(np.mean(bkgval1), np.mean(bkgval2)) assert_allclose(np.mean(bkgval1), BKG, atol=0.01) # test masked array with masked=False with axis bkgval2 = bkg.calc_background(data, masked=False, axis=1) assert not np.ma.isMaskedArray(bkgval2) assert_allclose(nanmean(bkgval2), BKG, atol=0.01) @pytest.mark.parametrize('rms_class', RMS_CLASS) def test_background_rms_masked(rms_class): bkgrms = rms_class(sigma_clip=None) mask = np.zeros(DATA.shape, dtype=bool) mask[0, 0:10] = True data = np.ma.MaskedArray(DATA, mask=mask) # test masked array with masked=True with axis rms1 = bkgrms(data, masked=True, axis=1) rms2 = bkgrms.calc_background_rms(data, masked=True, axis=1) assert np.ma.isMaskedArray(rms1) assert_allclose(np.mean(rms1), np.mean(rms2)) assert_allclose(np.mean(rms1), STD, atol=0.01) # test masked array with masked=False with axis rms3 = bkgrms.calc_background_rms(data, masked=False, axis=1) assert not np.ma.isMaskedArray(rms3) assert_allclose(nanmean(rms3), STD, atol=0.01) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_background_axis_tuple(bkg_class): bkg = bkg_class(sigma_clip=None) bkg_val1 = bkg.calc_background(DATA, axis=None) bkg_val2 = bkg.calc_background(DATA, axis=(0, 1)) assert_allclose(bkg_val1, bkg_val2) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_background_units(bkg_class): data = np.ones((100, 100)) << u.Jy bkg = bkg_class(sigma_clip=SIGMA_CLIP) bkgval = bkg.calc_background(data) assert isinstance(bkgval, u.Quantity) @pytest.mark.parametrize('rms_class', RMS_CLASS) def test_background_rms_units(rms_class): data = np.ones((100, 100)) << u.Jy bkgrms = rms_class(sigma_clip=SIGMA_CLIP) rmsval = bkgrms.calc_background_rms(data) assert isinstance(rmsval, u.Quantity) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_background_invalid_sigmaclip(bkg_class): match = 'sigma_clip must be an astropy SigmaClip instance or None' with pytest.raises(TypeError, match=match): bkg_class(sigma_clip=3) @pytest.mark.parametrize('rms_class', RMS_CLASS) def test_background_rms_invalid_sigmaclip(rms_class): match = 'sigma_clip must be an astropy SigmaClip instance or None' with pytest.raises(TypeError, match=match): rms_class(sigma_clip=3) @pytest.mark.parametrize('bkg_class', BKG_CLASS) def test_background_repr(bkg_class): bkg = bkg_class() bkg_repr = repr(bkg) assert bkg_repr == str(bkg) assert bkg_repr.startswith(f'{bkg.__class__.__name__}') @pytest.mark.parametrize('rms_class', RMS_CLASS) def test_background_rms_repr(rms_class): bkgrms = rms_class() rms_repr = repr(bkgrms) assert rms_repr == str(bkgrms) assert rms_repr.startswith(f'{bkgrms.__class__.__name__}') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/background/tests/test_interpolators.py0000644000175100001660000000473714755160622025026 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the interpolators module. """ import astropy.units as u import numpy as np import pytest from astropy.utils.exceptions import AstropyDeprecationWarning from photutils.background.background_2d import Background2D from photutils.background.interpolators import (BkgIDWInterpolator, BkgZoomInterpolator) def test_zoom_interp(): data = np.ones((300, 300)) bkg = Background2D(data, 100) mesh = np.array([[0.01, 0.01, 0.02], [0.01, 0.02, 0.03], [0.03, 0.03, 12.9]]) interp = BkgZoomInterpolator(clip=False) zoom = interp(mesh, **bkg._interp_kwargs) assert zoom.shape == (300, 300) with pytest.warns(AstropyDeprecationWarning): bkg = Background2D(data, 100, edge_method='crop') zoom2 = interp(mesh, **bkg._interp_kwargs) assert zoom2.shape == (300, 300) # test with units unit = u.nJy bkg = Background2D(data << unit, 100) interp = BkgZoomInterpolator(clip=False) zoom = interp(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(): bkg = Background2D(np.ones((300, 300)), 100) mesh = np.array([[0.01, 0.01, 0.02], [0.01, 0.02, 0.03], [0.03, 0.03, 12.9]]) interp1 = BkgZoomInterpolator(clip=False) zoom1 = interp1(mesh, **bkg._interp_kwargs) interp2 = BkgZoomInterpolator(clip=True) zoom2 = interp2(mesh, **bkg._interp_kwargs) minval = np.min(mesh) maxval = np.max(mesh) assert np.min(zoom1) < minval assert np.max(zoom1) > maxval assert np.min(zoom2) == minval assert np.max(zoom2) == maxval def test_idw_interp(): data = np.ones((300, 300)) interp = BkgIDWInterpolator() bkg = Background2D(data, 100, interpolator=interp) mesh = np.array([[0.01, 0.01, 0.02], [0.01, 0.02, 0.03], [0.03, 0.03, 12.9]]) zoom = interp(mesh, **bkg._interp_kwargs) assert zoom.shape == (300, 300) # test with units unit = u.nJy bkg = Background2D(data << unit, 100, interpolator=interp) zoom = interp(mesh << unit, **bkg._interp_kwargs) assert zoom.shape == (300, 300) # test repr cls_repr = repr(interp) assert cls_repr.startswith(f'{interp.__class__.__name__}') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/background/tests/test_local_background.py0000644000175100001660000000172014755160622025377 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the local_background module. """ import numpy as np import pytest from numpy.testing import assert_allclose from photutils.background import LocalBackground, MedianBackground def test_local_background(): data = np.ones((101, 101)) local_bkg = LocalBackground(5, 10, bkg_estimator=MedianBackground()) x = np.arange(1, 7) * 10 y = np.arange(1, 7) * 10 bkg = local_bkg(data, x, y) assert_allclose(bkg, np.ones(len(x))) # test scalar x and y bkg2 = local_bkg(data, x[2], y[2]) assert not isinstance(bkg2, np.ndarray) assert_allclose(bkg[2], bkg2) bkg3 = local_bkg(data, -100, -100) assert np.isnan(bkg3) match = "'positions' must not contain any non-finite" with pytest.raises(ValueError, match=match): _ = local_bkg(data, x[2], np.inf) cls_repr = repr(local_bkg) assert cls_repr.startswith(local_bkg.__class__.__name__) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6949267 photutils-2.2.0/photutils/centroids/0000755000175100001660000000000014755160634017211 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/centroids/__init__.py0000644000175100001660000000032514755160622021317 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools for centroiding sources. """ from .core import * # noqa: F401, F403 from .gaussian import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/centroids/core.py0000644000175100001660000005036314755160622020517 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ The module contains tools for centroiding sources. """ import inspect import warnings import numpy as np from astropy.utils.exceptions import AstropyUserWarning from photutils.utils._parameters import as_pair from photutils.utils._round import py2intround from photutils.utils.cutouts import _overlap_slices as overlap_slices __all__ = ['centroid_com', 'centroid_quadratic', 'centroid_sources'] def centroid_com(data, mask=None): """ Calculate the centroid of an n-dimensional array as its "center of mass" determined from `image moments `_. Non-finite values (e.g., NaN or inf) in the ``data`` array are automatically masked. Parameters ---------- data : `~numpy.ndarray` The input n-dimensional array. The image should be a background-subtracted cutout image containing a single source. mask : bool `~numpy.ndarray`, optional A boolean mask, with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Returns ------- centroid : `~numpy.ndarray` The coordinates of the centroid in pixel order (e.g., ``(x, y)`` or ``(x, y, z)``), not numpy axis order. Examples -------- >>> import numpy as np >>> from photutils.datasets import make_4gaussians_image >>> from photutils.centroids import centroid_com >>> data = make_4gaussians_image() >>> data -= np.median(data[0:30, 0:125]) >>> data = data[40:80, 70:110] >>> x1, y1 = centroid_com(data) >>> print(np.array((x1, y1))) [19.9796724 20.00992593] .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.centroids import centroid_com from photutils.datasets import make_4gaussians_image data = make_4gaussians_image() data -= np.median(data[0:30, 0:125]) data = data[40:80, 70:110] xycen = centroid_com(data) fig, ax = plt.subplots(1, 1, figsize=(8, 8)) ax.imshow(data, origin='lower', interpolation='nearest') ax.scatter(*xycen, color='red', marker='+', s=100, label='Centroid') ax.legend() """ # preserve input data - which should be a small cutout image data = data.copy() if mask is not None and mask is not np.ma.nomask: mask = np.asarray(mask, dtype=bool) if data.shape != mask.shape: raise ValueError('data and mask must have the same shape.') data[mask] = 0.0 badmask = ~np.isfinite(data) if np.any(badmask): warnings.warn('Input data contains non-finite values (e.g., NaN or ' 'inf) that were automatically masked.', AstropyUserWarning) data[badmask] = 0.0 total = np.sum(data) if total == 0: return np.array((np.nan, np.nan)) indices = np.ogrid[tuple(slice(0, i) for i in data.shape)] # note the output array is reversed to give (x, y) order return np.array([np.sum(indices[axis] * data) / total for axis in range(data.ndim)])[::-1] def centroid_quadratic(data, xpeak=None, ypeak=None, fit_boxsize=5, search_boxsize=None, mask=None): """ Calculate the centroid of an n-dimensional array by fitting a 2D quadratic polynomial. A second degree 2D polynomial is fit within a small region of the data defined by ``fit_boxsize`` to calculate the centroid position. The initial center of the fitting box can specified using the ``xpeak`` and ``ypeak`` keywords. If both ``xpeak`` and ``ypeak`` are `None`, then the box will be centered at the position of the maximum value in the input ``data``. If ``xpeak`` and ``ypeak`` are specified, the ``search_boxsize`` optional keyword can be used to further refine the initial center of the fitting box by searching for the position of the maximum pixel within a box of size ``search_boxsize``. `Vakili & Hogg (2016) `_ demonstrate that 2D quadratic centroiding comes very close to saturating the `CramÊr-Rao lower bound `_ in a wide range of conditions. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image data. The image should be a background-subtracted cutout image containing a single source. xpeak, ypeak : float or `None`, optional The initial guess of the position of the centroid. If either ``xpeak`` or ``ypeak`` is `None` then the position of the maximum value in the input ``data`` will be used as the initial guess. fit_boxsize : int or tuple of int, optional The size (in pixels) of the box used to define the fitting region. If ``fit_boxsize`` has two elements, they must be in ``(ny, nx)`` order. If ``fit_boxsize`` is a scalar then a square box of size ``fit_boxsize`` will be used. ``fit_boxsize`` must have odd values for both axes. search_boxsize : int or tuple of int, optional The size (in pixels) of the box used to search for the maximum pixel value if ``xpeak`` and ``ypeak`` are both specified. If ``fit_boxsize`` has two elements, they must be in ``(ny, nx)`` order. If ``search_boxsize`` is a scalar then a square box of size ``search_boxsize`` will be used. ``search_boxsize`` must have odd values for both axes. This parameter is ignored if either ``xpeak`` or ``ypeak`` is `None`. In that case, the entire array is searched for the maximum value. mask : bool `~numpy.ndarray`, optional A boolean mask, with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from calculations. Returns ------- centroid : `~numpy.ndarray` The ``x, y`` coordinates of the centroid. Notes ----- Use ``fit_boxsize = (3, 3)`` to match the work of `Vakili & Hogg (2016) `_ for their 2D second-order polynomial centroiding method. Because this centroid is based on fitting data, it can fail for many reasons, returning (np.nan, np.nan): * quadratic fit failed * quadratic fit does not have a maximum * quadratic fit maximum falls outside image * not enough unmasked data points (6 are required) Also note that a fit is not performed if the maximum data value is at the edge of the data. In this case, the position of the maximum pixel will be returned. References ---------- .. [1] Vakili and Hogg 2016, "Do fast stellar centroiding methods saturate the CramÊr-Rao lower bound?", `arXiv:1610.05873 `_ Examples -------- >>> import numpy as np >>> from photutils.datasets import make_4gaussians_image >>> from photutils.centroids import centroid_quadratic >>> data = make_4gaussians_image() >>> data -= np.median(data[0:30, 0:125]) >>> data = data[40:80, 70:110] >>> x1, y1 = centroid_quadratic(data) >>> print(np.array((x1, y1))) [19.94009505 20.06884997] .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.centroids import centroid_quadratic from photutils.datasets import make_4gaussians_image data = make_4gaussians_image() data -= np.median(data[0:30, 0:125]) data = data[40:80, 70:110] xycen = centroid_quadratic(data) fig, ax = plt.subplots(1, 1, figsize=(8, 8)) ax.imshow(data, origin='lower', interpolation='nearest') ax.scatter(*xycen, color='red', marker='+', s=100, label='Centroid') ax.legend() """ if ((xpeak is None and ypeak is not None) or (xpeak is not None and ypeak is None)): raise ValueError('xpeak and ypeak must both be input or "None"') if xpeak is not None and ((xpeak < 0) or (xpeak > data.shape[1] - 1)): raise ValueError('xpeak is outside of the input data') if ypeak is not None and ((ypeak < 0) or (ypeak > data.shape[0] - 1)): raise ValueError('ypeak is outside of the input data') # preserve input data - which should be a small cutout image data = np.asanyarray(data, dtype=float).copy() ny, nx = data.shape badmask = ~np.isfinite(data) if mask is not None: if data.shape != mask.shape: raise ValueError('data and mask must have the same shape.') data[mask] = np.nan badmask &= ~mask if np.any(badmask): warnings.warn('Input data contains non-finite values (e.g., NaN or ' 'inf) that were automatically masked.', AstropyUserWarning) data[badmask] = np.nan fit_boxsize = as_pair('fit_boxsize', fit_boxsize, lower_bound=(0, 1), upper_bound=data.shape, check_odd=True) if np.prod(fit_boxsize) < 6: raise ValueError('fit_boxsize is too small. 6 values are required ' 'to fit a 2D quadratic polynomial.') if xpeak is None or ypeak is None: yidx, xidx = np.unravel_index(np.nanargmax(data), data.shape) else: xidx = py2intround(xpeak) yidx = py2intround(ypeak) if search_boxsize is not None: search_boxsize = as_pair('search_boxsize', search_boxsize, lower_bound=(0, 1), upper_bound=data.shape, check_odd=True) slc_data, _ = overlap_slices(data.shape, search_boxsize, (yidx, xidx), mode='trim') cutout = data[slc_data] yidx, xidx = np.unravel_index(np.nanargmax(cutout), cutout.shape) xidx += slc_data[1].start yidx += slc_data[0].start # if peak is at the edge of the data, return the position of the maximum if xidx in (0, nx - 1) or yidx in (0, ny - 1): warnings.warn('maximum value is at the edge of the data and its ' 'position was returned; no quadratic fit was ' 'performed', AstropyUserWarning) return np.array((xidx, yidx), dtype=float) # extract the fitting region slc_data, _ = overlap_slices(data.shape, fit_boxsize, (yidx, xidx), mode='trim') xidx0, xidx1 = (slc_data[1].start, slc_data[1].stop) yidx0, yidx1 = (slc_data[0].start, slc_data[0].stop) # shift the fitting box if it was clipped by the data edge if (xidx1 - xidx0) < fit_boxsize[1]: if xidx0 == 0: xidx1 = min(nx, xidx0 + fit_boxsize[1]) if xidx1 == nx: xidx0 = max(0, xidx1 - fit_boxsize[1]) if (yidx1 - yidx0) < fit_boxsize[0]: if yidx0 == 0: yidx1 = min(ny, yidx0 + fit_boxsize[0]) if yidx1 == ny: yidx0 = max(0, yidx1 - fit_boxsize[0]) cutout = data[yidx0:yidx1, xidx0:xidx1].ravel() if np.count_nonzero(~np.isnan(cutout)) < 6: warnings.warn('at least 6 unmasked data points are required to ' 'perform a 2D quadratic fit', AstropyUserWarning) return np.array((np.nan, np.nan)) # fit a 2D quadratic polynomial to the fitting region xi = np.arange(xidx0, xidx1) yi = np.arange(yidx0, yidx1) x, y = np.meshgrid(xi, yi) x = x.ravel() y = y.ravel() coeff_matrix = np.vstack((np.ones_like(x), x, y, x * y, x * x, y * y)).T # remove NaNs from data to be fit mask = ~np.isnan(cutout) if np.any(mask): coeff_matrix = coeff_matrix[mask] cutout = cutout[mask] try: c = np.linalg.lstsq(coeff_matrix, cutout, rcond=None)[0] except np.linalg.LinAlgError: # pragma: no cover warnings.warn('quadratic fit failed', AstropyUserWarning) return np.array((np.nan, np.nan)) # analytically find the maximum of the polynomial _, c10, c01, c11, c20, c02 = c det = 4 * c20 * c02 - c11**2 if det <= 0 or ((c20 > 0.0 and c02 >= 0.0) or (c20 >= 0.0 and c02 > 0.0)): # pragma: no cover warnings.warn('quadratic fit does not have a maximum', AstropyUserWarning) return np.array((np.nan, np.nan)) xm = (c01 * c11 - 2.0 * c02 * c10) / det ym = (c10 * c11 - 2.0 * c20 * c01) / det if 0.0 < xm < (nx - 1.0) and 0.0 < ym < (ny - 1.0): xycen = np.array((xm, ym), dtype=float) else: # pragma: no cover warnings.warn('quadratic polynomial maximum value falls outside ' 'of the image', AstropyUserWarning) return np.array((np.nan, np.nan)) return xycen def centroid_sources(data, xpos, ypos, box_size=11, footprint=None, mask=None, centroid_func=centroid_com, **kwargs): """ Calculate the centroid of sources at the defined positions. A cutout image centered on each input position will be used to calculate the centroid position. The cutout image is defined either using the ``box_size`` or ``footprint`` keyword. The ``footprint`` keyword can be used to create a non-rectangular cutout image. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image data. The image should be background-subtracted. xpos, ypos : float or array_like of float The initial ``x`` and ``y`` pixel position(s) of the center position. A cutout image centered on this position be used to calculate the centroid. box_size : int or array_like of int, optional The size of the cutout image along each axis. If ``box_size`` is a number, then a square cutout of ``box_size`` will be created. If ``box_size`` has two elements, they must be in ``(ny, nx)`` order. ``box_size`` must have odd values for both axes. Either ``box_size`` or ``footprint`` must be defined. If they are both defined, then ``footprint`` overrides ``box_size``. footprint : bool `~numpy.ndarray`, optional A 2D boolean array where `True` values describe the local footprint region to cutout. ``footprint`` can be used to create a non-rectangular cutout image, in which case the input ``xpos`` and ``ypos`` represent the center of the minimal bounding box for the input ``footprint``. ``box_size=(n, m)`` is equivalent to ``footprint=np.ones((n, m))``. Either ``box_size`` or ``footprint`` must be defined. If they are both defined, then ``footprint`` overrides ``box_size``. 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. centroid_func : callable, optional A callable object (e.g., function or class) that is used to calculate the centroid of a 2D array. The ``centroid_func`` must accept a 2D `~numpy.ndarray`, have a ``mask`` keyword and optionally an ``error`` keyword. The callable object must return a tuple of two 1D `~numpy.ndarray`, representing the x and y centroids. The default is `~photutils.centroids.centroid_com`. **kwargs : dict, optional Any additional keyword arguments accepted by the ``centroid_func``. Returns ------- xcentroid, ycentroid : `~numpy.ndarray` The ``x`` and ``y`` pixel position(s) of the centroids. NaNs will be returned where the centroid failed. This is usually due a ``box_size`` that is too small when using a fitting-based centroid function (e.g., `centroid_1dg`, `centroid_2dg`, or `centroid_quadratic`). Examples -------- >>> import numpy as np >>> from photutils.centroids import centroid_2dg, centroid_sources >>> from photutils.datasets import make_4gaussians_image >>> data = make_4gaussians_image() >>> data -= np.median(data[0:30, 0:125]) >>> x_init = (25, 91, 151, 160) >>> y_init = (40, 61, 24, 71) >>> x, y = centroid_sources(data, x_init, y_init, box_size=25, ... centroid_func=centroid_2dg) >>> print(x) # doctest: +FLOAT_CMP [ 24.96807828 89.98684636 149.96545721 160.18810915] >>> print(y) # doctest: +FLOAT_CMP [40.03657613 60.01836631 24.96777946 69.80208702] .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.centroids import centroid_2dg, centroid_sources from photutils.datasets import make_4gaussians_image data = make_4gaussians_image() data -= np.median(data[0:30, 0:125]) x_init = (25, 91, 151, 160) y_init = (40, 61, 24, 71) x, y = centroid_sources(data, x_init, y_init, box_size=25, centroid_func=centroid_2dg) plt.figure(figsize=(8, 4)) plt.imshow(data, origin='lower', interpolation='nearest') plt.scatter(x, y, marker='+', s=80, color='red', label='Centroids') plt.legend() plt.tight_layout() """ xpos = np.atleast_1d(xpos) ypos = np.atleast_1d(ypos) if xpos.ndim != 1: raise ValueError('xpos must be a 1D array.') if ypos.ndim != 1: raise ValueError('ypos must be a 1D array.') if (np.any(np.min(xpos) < 0) or np.any(np.min(ypos) < 0) or np.any(np.max(xpos) > data.shape[1] - 1) or np.any(np.max(ypos) > data.shape[0] - 1)): raise ValueError('xpos, ypos values contains points outside of ' 'input data') if footprint is None: if box_size is None: raise ValueError('box_size or footprint must be defined.') box_size = as_pair('box_size', box_size, lower_bound=(0, 1), check_odd=True) footprint = np.ones(box_size, dtype=bool) else: footprint = np.asanyarray(footprint, dtype=bool) if footprint.ndim != 2: raise ValueError('footprint must be a 2D array.') spec = inspect.signature(centroid_func) if 'mask' not in spec.parameters: raise ValueError('The input "centroid_func" must have a "mask" ' 'keyword.') # drop any **kwargs not supported by the centroid_func centroid_kwargs = {key: val for key, val in kwargs.items() if key in spec.parameters} xcentroids = [] ycentroids = [] for xp, yp in zip(xpos, ypos, strict=True): slices_large, slices_small = overlap_slices(data.shape, footprint.shape, (yp, xp)) data_cutout = data[slices_large] footprint_mask = np.logical_not(footprint) # trim footprint mask if it has only partial overlap on the data footprint_mask = footprint_mask[slices_small] if mask is not None: # combine the input mask cutout and footprint mask mask_cutout = np.logical_or(mask[slices_large], footprint_mask) else: mask_cutout = footprint_mask if np.all(mask_cutout): raise ValueError(f'The cutout for the source at ({xp, yp}) is ' 'completely masked. Please check your input ' 'mask and footprint. Also note that footprint ' 'must be a small, local footprint.') centroid_kwargs.update({'mask': mask_cutout}) error = centroid_kwargs.get('error') if error is not None: centroid_kwargs['error'] = error[slices_large] # remove xpeak and ypeak from the dict and add back only if both # are specified and not None xpeak = centroid_kwargs.pop('xpeak', None) ypeak = centroid_kwargs.pop('ypeak', None) if xpeak is not None and ypeak is not None: centroid_kwargs['xpeak'] = xpeak - slices_large[1].start centroid_kwargs['ypeak'] = ypeak - slices_large[0].start try: xcen, ycen = centroid_func(data_cutout, **centroid_kwargs) except (ValueError, TypeError): xcen, ycen = np.nan, np.nan xcentroids.append(xcen + slices_large[1].start) ycentroids.append(ycen + slices_large[0].start) return np.array(xcentroids), np.array(ycentroids) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/centroids/gaussian.py0000644000175100001660000002313114755160622021372 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ The module contains tools for centroiding sources using Gaussians. """ import warnings import numpy as np from astropy.modeling.fitting import TRFLSQFitter from astropy.modeling.models import Gaussian1D, Gaussian2D from astropy.utils.exceptions import AstropyUserWarning from photutils.utils._quantity_helpers import process_quantities __all__ = ['centroid_1dg', 'centroid_2dg'] def centroid_1dg(data, error=None, mask=None): """ Calculate the centroid of a 2D array by fitting 1D Gaussians to the marginal ``x`` and ``y`` distributions of the array. Non-finite values (e.g., NaN or inf) in the ``data`` or ``error`` arrays are automatically masked. These masks are combined. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image data. The image should be a background-subtracted cutout image containing a single source. error : 2D `~numpy.ndarray`, optional The 2D array of the 1-sigma errors of the input ``data``. mask : 2D bool `~numpy.ndarray`, optional A boolean mask, with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Returns ------- centroid : `~numpy.ndarray` The ``x, y`` coordinates of the centroid. Examples -------- >>> import numpy as np >>> from photutils.datasets import make_4gaussians_image >>> from photutils.centroids import centroid_1dg >>> data = make_4gaussians_image() >>> data -= np.median(data[0:30, 0:125]) >>> data = data[40:80, 70:110] >>> x1, y1 = centroid_1dg(data) >>> print(np.array((x1, y1))) [19.96553246 20.04952841] .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.centroids import centroid_1dg from photutils.datasets import make_4gaussians_image data = make_4gaussians_image() data -= np.median(data[0:30, 0:125]) data = data[40:80, 70:110] xycen = centroid_1dg(data) fig, ax = plt.subplots(1, 1, figsize=(8, 8)) ax.imshow(data, origin='lower', interpolation='nearest') ax.scatter(*xycen, color='red', marker='+', s=100, label='Centroid') ax.legend() """ (data, error), _ = process_quantities((data, error), ('data', 'error')) data = np.ma.asanyarray(data) if mask is not None and mask is not np.ma.nomask: mask = np.asanyarray(mask) if data.shape != mask.shape: raise ValueError('data and mask must have the same shape.') data.mask |= mask if np.any(~np.isfinite(data)): data = np.ma.masked_invalid(data) warnings.warn('Input data contains non-finite values (e.g., NaN or ' 'inf) that were automatically masked.', AstropyUserWarning) if error is not None: error = np.ma.masked_invalid(error) if data.shape != error.shape: raise ValueError('data and error must have the same shape.') data.mask |= error.mask error.mask = data.mask xy_error = [np.sqrt(np.ma.sum(error**2, axis=i)) for i in (0, 1)] xy_weights = [(1.0 / xy_error[i].clip(min=1.0e-30)) for i in (0, 1)] else: xy_weights = [np.ones(data.shape[i]) for i in (1, 0)] # assign zero weight where an entire row or column is masked if np.any(data.mask): bad_idx = [np.all(data.mask, axis=i) for i in (0, 1)] for i in (0, 1): xy_weights[i][bad_idx[i]] = 0.0 xy_data = [np.ma.sum(data, axis=i).data for i in (0, 1)] # 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) def _gaussian1d_moments(data, mask=None): """ Estimate 1D Gaussian parameters from the moments of 1D data. This function can be useful for providing initial parameter values when fitting a 1D Gaussian to the ``data``. Parameters ---------- data : 1D `~numpy.ndarray` The 1D data array. mask : 1D bool `~numpy.ndarray`, optional A boolean mask, with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Returns ------- amplitude, mean, stddev : float The estimated parameters of a 1D Gaussian. """ if np.any(~np.isfinite(data)): data = np.ma.masked_invalid(data) warnings.warn('Input data contains non-finite values (e.g., NaN or ' 'inf) that were automatically masked.', AstropyUserWarning) else: data = np.ma.array(data) if mask is not None and mask is not np.ma.nomask: mask = np.asanyarray(mask) if data.shape != mask.shape: raise ValueError('data and mask must have the same shape.') data.mask |= mask data.fill_value = 0.0 data = data.filled() x = np.arange(data.size) x_mean = np.sum(x * data) / np.sum(data) x_stddev = np.sqrt(abs(np.sum(data * (x - x_mean) ** 2) / np.sum(data))) amplitude = np.ptp(data) return amplitude, x_mean, x_stddev def centroid_2dg(data, error=None, mask=None): """ Calculate the centroid of a 2D array by fitting a 2D Gaussian to the array. Non-finite values (e.g., NaN or inf) in the ``data`` or ``error`` arrays are automatically masked. These masks are combined. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image data. The image should be a background-subtracted cutout image containing a single source. error : 2D `~numpy.ndarray`, optional The 2D array of the 1-sigma errors of the input ``data``. mask : 2D bool `~numpy.ndarray`, optional A boolean mask, with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Returns ------- centroid : `~numpy.ndarray` The ``x, y`` coordinates of the centroid. 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.98519436 20.0149016 ] .. plot:: import matplotlib.pyplot as plt import numpy as np from photutils.centroids import centroid_2dg from photutils.datasets import make_4gaussians_image data = make_4gaussians_image() data -= np.median(data[0:30, 0:125]) data = data[40:80, 70:110] xycen = centroid_2dg(data) fig, ax = plt.subplots(1, 1, figsize=(8, 8)) ax.imshow(data, origin='lower', interpolation='nearest') ax.scatter(*xycen, color='red', marker='+', s=100, label='Centroid') ax.legend() """ # prevent circular import from photutils.morphology import data_properties (data, error), _ = process_quantities((data, error), ('data', 'error')) data = np.ma.asanyarray(data) if mask is not None and mask is not np.ma.nomask: mask = np.asanyarray(mask) if data.shape != mask.shape: raise ValueError('data and mask must have the same shape.') data.mask |= mask if np.any(~np.isfinite(data)): data = np.ma.masked_invalid(data) warnings.warn('Input data contains non-finite values (e.g., NaN or ' 'inf) that were automatically masked.', AstropyUserWarning) if error is not None: error = np.ma.masked_invalid(error) if data.shape != error.shape: raise ValueError('data and error must have the same shape.') data.mask |= error.mask weights = 1.0 / error.clip(min=1.0e-30) else: weights = np.ones(data.shape) if np.ma.count(data) < 6: raise ValueError('Input data must have a least 6 unmasked values to ' 'fit a 2D Gaussian.') # assign zero weight to masked pixels if data.mask is not np.ma.nomask: weights[data.mask] = 0.0 mask = data.mask data.fill_value = 0.0 data = data.filled() # Subtract the minimum of the data to make the data values positive. # This prevents issues with the moment estimation in data_properties. # Moments from negative data values can yield undefined Gaussian # parameters, e.g., x/y_stddev. props = data_properties(data - np.min(data), mask=mask) g_init = Gaussian2D(amplitude=np.ptp(data), x_mean=props.xcentroid, y_mean=props.ycentroid, x_stddev=props.semimajor_sigma.value, y_stddev=props.semiminor_sigma.value, theta=props.orientation.value) # Gaussian2D [x/y]_stddev are bounded to be strictly positive fitter = TRFLSQFitter() y, x = np.indices(data.shape) with warnings.catch_warnings(record=True) as fit_warnings: gfit = fitter(g_init, x, y, data, weights=weights) if len(fit_warnings) > 0: warnings.warn('The fit may not have converged. Please check your ' 'results.', AstropyUserWarning) return np.array([gfit.x_mean.value, gfit.y_mean.value]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6959267 photutils-2.2.0/photutils/centroids/tests/0000755000175100001660000000000014755160634020353 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/centroids/tests/__init__.py0000644000175100001660000000000014755160622022447 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/centroids/tests/test_core.py0000644000175100001660000003616314755160622022722 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the core module. """ from contextlib import nullcontext 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 from photutils.centroids.core import (centroid_com, centroid_quadratic, centroid_sources) from photutils.centroids.gaussian import centroid_1dg, centroid_2dg from photutils.datasets import make_4gaussians_image, make_noise_image @pytest.fixture(name='test_simple_data') def fixture_test_simple_data(): xcen = 25.7 ycen = 26.2 data = np.zeros((3, 3)) data[0:2, 1] = 1.0 data[1, 0:2] = 1.0 data[1, 1] = 2.0 return data, xcen, ycen @pytest.fixture(name='test_data') def fixture_test_data(): 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 # NOTE: the fitting routines in astropy use scipy.optimize @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_comquad(test_simple_data, x_std, y_std, theta, units): data, xcen, ycen = test_simple_data if units: data = data * u.nJy model = Gaussian2D(2.4, xcen, ycen, x_stddev=x_std, y_stddev=y_std, theta=theta) y, x = np.mgrid[0:50, 0:47] data = model(x, y) xc, yc = centroid_com(data) assert_allclose((xc, yc), (xcen, ycen), rtol=0, atol=1.0e-3) xc, yc = centroid_quadratic(data) assert_allclose((xc, yc), (xcen, ycen), rtol=0, atol=0.015) # test with mask mask = np.zeros(data.shape, dtype=bool) data[10, 10] = 1.0e5 mask[10, 10] = True xc, yc = centroid_com(data, mask=mask) assert_allclose((xc, yc), (xcen, ycen), rtol=0, atol=1.0e-3) xc, yc = centroid_quadratic(data, mask=mask) assert_allclose((xc, yc), (xcen, ycen), rtol=0, atol=0.015) @pytest.mark.parametrize('use_mask', [True, False]) def test_centroid_comquad_nan_withmask(use_mask): xc_ref = 24.7 yc_ref = 25.2 model = Gaussian2D(2.4, xc_ref, yc_ref, x_stddev=5.0, y_stddev=5.0) y, x = np.mgrid[0:50, 0:50] data = model(x, y) data[20, :] = np.nan if use_mask: mask = np.zeros(data.shape, dtype=bool) mask[20, :] = True nwarn = 0 ctx = nullcontext() else: mask = None nwarn = 1 match = 'Input data contains non-finite values' ctx = pytest.warns(AstropyUserWarning, match=match) with ctx as warnlist: xc, yc = centroid_com(data, mask=mask) assert_allclose(xc, xc_ref, rtol=0, atol=1.0e-3) assert yc > yc_ref if nwarn == 1: assert len(warnlist) == nwarn with ctx as warnlist: xc, yc = centroid_quadratic(data, mask=mask) assert_allclose(xc, xc_ref, rtol=0, atol=0.15) if nwarn == 1: assert len(warnlist) == nwarn def test_centroid_com_allmask(): xc_ref = 24.7 yc_ref = 25.2 model = Gaussian2D(2.4, xc_ref, yc_ref, x_stddev=5.0, y_stddev=5.0) y, x = np.mgrid[0:50, 0:50] data = model(x, y) mask = np.ones(data.shape, dtype=bool) xc, yc = centroid_com(data, mask=mask) assert np.isnan(xc) assert np.isnan(yc) data = np.zeros((25, 25)) xc, yc = centroid_com(data, mask=None) assert np.isnan(xc) assert np.isnan(yc) def test_centroid_com_invalid_inputs(): data = np.zeros((4, 4)) mask = np.zeros((2, 2), dtype=bool) match = 'data and mask must have the same shape' with pytest.raises(ValueError, match=match): centroid_com(data, mask=mask) def test_centroid_quadratic_xypeak(): data = np.zeros((11, 11)) data[5, 5] = 100 data[7, 7] = 110 data[9, 9] = 120 xycen1 = centroid_quadratic(data, fit_boxsize=3) assert_allclose(xycen1, (9, 9)) xycen2 = centroid_quadratic(data, xpeak=5, ypeak=5, fit_boxsize=3) assert_allclose(xycen2, (5, 5)) xycen3 = centroid_quadratic(data, xpeak=5, ypeak=5, fit_boxsize=3, search_boxsize=5) assert_allclose(xycen3, (7, 7)) match = 'xpeak is outside of the input data' with pytest.raises(ValueError, match=match): centroid_quadratic(data, xpeak=15, ypeak=5) match = 'ypeak is outside of the input data' with pytest.raises(ValueError, match=match): centroid_quadratic(data, xpeak=5, ypeak=15) match = 'xpeak is outside of the input data' with pytest.raises(ValueError, match=match): centroid_quadratic(data, xpeak=15, ypeak=15) def test_centroid_quadratic_nan(): gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) error = make_noise_image(data.shape, mean=0., stddev=2.4, seed=123) data += error data[50, 50] = np.nan mask = ~np.isfinite(data) xycen = centroid_quadratic(data, xpeak=47, ypeak=52, mask=mask) assert_allclose(xycen, [47.58324, 51.827182]) def test_centroid_quadratic_npts(): data = np.zeros((3, 3)) data[1, 1] = 1 mask = np.zeros(data.shape, dtype=bool) mask[0, :] = True mask[2, :] = True match = 'at least 6 unmasked data points' with pytest.warns(AstropyUserWarning, match=match): centroid_quadratic(data, mask=mask) def test_centroid_quadratic_invalid_inputs(): data = np.zeros((4, 4)) mask = np.zeros((2, 2), dtype=bool) match = 'xpeak and ypeak must both be input or "None"' with pytest.raises(ValueError, match=match): centroid_quadratic(data, xpeak=3, ypeak=None) with pytest.raises(ValueError, match=match): centroid_quadratic(data, xpeak=None, ypeak=3) match = 'fit_boxsize must have 1 or 2 elements' with pytest.raises(ValueError, match=match): centroid_quadratic(data, fit_boxsize=(2, 2, 2)) match = 'fit_boxsize must have an odd value for both axes' with pytest.raises(ValueError, match=match): centroid_quadratic(data, fit_boxsize=(-2, 2)) with pytest.raises(ValueError, match=match): centroid_quadratic(data, fit_boxsize=(2, 2)) match = 'data and mask must have the same shape' with pytest.raises(ValueError, match=match): centroid_quadratic(data, mask=mask) def test_centroid_quadratic_edge(): data = np.zeros((11, 11)) data[1, 1] = 100 data[9, 9] = 100 xycen = centroid_quadratic(data, xpeak=1, ypeak=1, fit_boxsize=5) assert_allclose(xycen, (0.923077, 0.923077)) xycen = centroid_quadratic(data, xpeak=9, ypeak=9, fit_boxsize=5) assert_allclose(xycen, (9.076923, 9.076923)) data = np.zeros((5, 5)) data[0, 0] = 100 match = 'maximum value is at the edge' with pytest.warns(AstropyUserWarning, match=match): xycen = centroid_quadratic(data) assert_allclose(xycen, (0, 0)) class TestCentroidSources: @staticmethod def test_centroid_sources(): theta = np.pi / 6.0 model = Gaussian2D(2.4, 25.7, 26.2, x_stddev=3.2, y_stddev=5.7, theta=theta) y, x = np.mgrid[0:50, 0:47] data = model(x, y) error = np.ones(data.shape, dtype=float) mask = np.zeros(data.shape, dtype=bool) mask[10, 10] = True xpos = [25.0] ypos = [26.0] xc, yc = centroid_sources(data, xpos, ypos, box_size=21, mask=mask) assert_allclose(xc, (25.67,), atol=1e-1) assert_allclose(yc, (26.18,), atol=1e-1) xc, yc = centroid_sources(data, xpos, ypos, error=error, box_size=11, centroid_func=centroid_1dg) assert_allclose(xc, (25.67,), atol=1e-1) assert_allclose(yc, (26.41,), atol=1e-1) match = 'xpos must be a 1D array' with pytest.raises(ValueError, match=match): centroid_sources(data, [[25]], 26, box_size=11) match = 'ypos must be a 1D array' with pytest.raises(ValueError, match=match): centroid_sources(data, 25, [[26]], box_size=11) match = 'box_size must have 1 or 2 elements' with pytest.raises(ValueError, match=match): centroid_sources(data, 25, 26, box_size=(1, 2, 3)) match = 'box_size or footprint must be defined' with pytest.raises(ValueError, match=match): centroid_sources(data, 25, 26, box_size=None, footprint=None) match = 'footprint must be a 2D array' with pytest.raises(ValueError, match=match): centroid_sources(data, 25, 26, footprint=np.ones((3, 3, 3))) def test_func(data): return data match = 'The input "centroid_func" must have a "mask" keyword' with pytest.raises(ValueError, match=match): centroid_sources(data, [25], 26, centroid_func=test_func) @pytest.mark.parametrize('centroid_func', [centroid_com, centroid_quadratic, centroid_1dg, centroid_2dg]) def test_xypos(self, test_data, centroid_func): data = test_data[0] match = 'xpos, ypos values contains points outside of 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): 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) assert_allclose(ycen, yres) 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) xcen, ycen = centroid_sources(data, xpos, ypos, box_size=3, centroid_func=centroid_quadratic) assert_allclose(xcen, xres) assert_allclose(ycen, yres) @pytest.mark.filterwarnings(r'ignore:.*no quadratic fit was performed') def test_centroid_quadratic_kwargs(self): data = np.zeros((11, 11)) data[5, 5] = 100 data[7, 7] = 110 data[9, 9] = 120 xycen1 = centroid_sources(data, xpos=5, ypos=5, box_size=9, centroid_func=centroid_quadratic, fit_boxsize=3) assert_allclose(xycen1, ([9], [9])) xycen2 = centroid_sources(data, xpos=7, ypos=7, box_size=5, centroid_func=centroid_quadratic, fit_boxsize=3) assert_allclose(xycen2, ([9], [9])) xycen3 = centroid_sources(data, xpos=7, ypos=7, box_size=5, centroid_func=centroid_quadratic, xpeak=7, ypeak=7, fit_boxsize=3) assert_allclose(xycen3, ([7], [7])) xycen4 = centroid_sources(data, xpos=5, ypos=5, box_size=5, centroid_func=centroid_quadratic, xpeak=5, ypeak=5, fit_boxsize=3) assert_allclose(xycen4, ([5], [5])) xycen5 = centroid_sources(data, xpos=5, ypos=5, box_size=5, centroid_func=centroid_quadratic, fit_boxsize=5) assert_allclose(xycen5, ([7], [7])) def test_centroid_quadratic_mask(self): """ Regression test to check that when a mask is input the original data is not alterned. """ xc_ref = 24.7 yc_ref = 25.2 model = Gaussian2D(2.4, xc_ref, yc_ref, x_stddev=5.0, y_stddev=5.0) y, x = np.mgrid[0:51, 0:51] data = model(x, y) mask = data < 1 xycen = centroid_quadratic(data, mask=mask) assert ~np.any(np.isnan(data)) assert_allclose(xycen, (xc_ref, yc_ref), atol=0.01) def test_mask(self, test_data): 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): 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) def test_xypeaks_none(self, test_data): 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_cutout_mask(): """ Test that the cutout is not completely masked (see #1514). """ data = make_4gaussians_image() x_init = (25, 91, 151, 160) y_init = (40, 61, 24, 71) footprint = np.zeros((3, 3)) match = 'is completely masked' with pytest.raises(ValueError, match=match): _ = centroid_sources(data, x_init, y_init, footprint=footprint, centroid_func=centroid_com) footprint = np.zeros(data.shape, dtype=bool) with pytest.raises(ValueError, match=match): _ = centroid_sources(data, x_init, y_init, footprint=footprint, centroid_func=centroid_com) mask = np.ones(data.shape, dtype=bool) with pytest.raises(ValueError, match=match): _ = centroid_sources(data, x_init, y_init, box_size=11, mask=mask) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/centroids/tests/test_gaussian.py0000644000175100001660000001211614755160622023574 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the core module. """ from contextlib import nullcontext import astropy.units as u import numpy as np import pytest from astropy.modeling.models import Gaussian1D, Gaussian2D from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose from photutils.centroids.gaussian import (_gaussian1d_moments, centroid_1dg, centroid_2dg) @pytest.fixture(name='test_data') def fixture_test_data(): xcen = 25.7 ycen = 26.2 data = np.zeros((3, 3)) data[0:2, 1] = 1.0 data[1, 0:2] = 1.0 data[1, 1] = 2.0 return data, xcen, ycen # NOTE: the fitting routines in astropy use scipy.optimize @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): xcen = 25.7 ycen = 26.2 model = Gaussian2D(2.4, xcen, ycen, x_stddev=x_std, y_stddev=y_std, theta=theta) y, x = np.mgrid[0:50, 0:47] data = model(x, y) error = np.sqrt(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), (xcen, ycen), rtol=0, atol=1.0e-3) xc, yc = centroid_2dg(data) assert_allclose((xc, yc), (xcen, ycen), rtol=0, atol=1.0e-3) # test with errors xc, yc = centroid_1dg(data, error=error) assert_allclose((xc, yc), (xcen, ycen), rtol=0, atol=1.0e-3) xc, yc = centroid_2dg(data, error=error) assert_allclose((xc, yc), (xcen, ycen), rtol=0, atol=1.0e-3) # test with mask mask = np.zeros(data.shape, dtype=bool) data[10, 10] = value mask[10, 10] = True xc, yc = centroid_1dg(data, mask=mask) assert_allclose((xc, yc), (xcen, ycen), rtol=0, atol=1.0e-3) xc, yc = centroid_2dg(data, mask=mask) assert_allclose((xc, yc), (xcen, ycen), rtol=0, atol=1.0e-3) @pytest.mark.parametrize('use_mask', [True, False]) def test_centroids_nan_withmask(use_mask): xc_ref = 24.7 yc_ref = 25.2 model = Gaussian2D(2.4, xc_ref, yc_ref, x_stddev=5.0, y_stddev=5.0) y, x = np.mgrid[0:50, 0:50] data = model(x, y) data[20, :] = np.nan if use_mask: mask = np.zeros(data.shape, dtype=bool) mask[20, :] = True nwarn = 0 ctx = nullcontext() else: mask = None nwarn = 1 match = 'Input data contains non-finite values' ctx = pytest.warns(AstropyUserWarning, match=match) with ctx as warnlist: xc, yc = centroid_1dg(data, mask=mask) assert_allclose([xc, yc], [xc_ref, yc_ref], rtol=0, atol=1.0e-3) if nwarn == 1: assert len(warnlist) == nwarn with ctx as warnlist: xc, yc = centroid_2dg(data, mask=mask) assert_allclose([xc, yc], [xc_ref, yc_ref], rtol=0, atol=1.0e-3) if nwarn == 1: assert len(warnlist) == nwarn def test_invalid_mask_shape(): 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) with pytest.raises(ValueError, match=match): _gaussian1d_moments(data, mask=mask) def test_invalid_error_shape(): 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(): data = np.ones((2, 2)) match = 'Input data must have a least 6 unmasked values to fit' with pytest.raises(ValueError, match=match): centroid_2dg(data) def test_gaussian1d_moments(): x = np.arange(100) desired = (75, 50, 5) g = Gaussian1D(*desired) data = g(x) result = _gaussian1d_moments(data) assert_allclose(result, desired, rtol=0, atol=1.0e-6) data[0] = 1.0e5 mask = np.zeros(data.shape).astype(bool) mask[0] = True result = _gaussian1d_moments(data, mask=mask) assert_allclose(result, desired, rtol=0, atol=1.0e-6) data[0] = np.nan mask = np.zeros(data.shape).astype(bool) mask[0] = True match = 'Input data contains non-finite values' ctx = pytest.warns(AstropyUserWarning, match=match) with ctx as warnlist: result = _gaussian1d_moments(data, mask=mask) assert_allclose(result, desired, rtol=0, atol=1.0e-6) assert len(warnlist) == 1 def test_gaussian2d_warning(): yy, xx = np.mgrid[:51, :51] model = Gaussian2D(x_mean=24.17, y_mean=25.87, x_stddev=1.7, y_stddev=4.7) data = model(xx, yy) match = 'The fit may not have converged' with pytest.warns(AstropyUserWarning, match=match): centroid_2dg(data + 100000) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/conftest.py0000644000175100001660000000170314755160622017414 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Configuration file for the pytest test suite. """ try: from pytest_astropy_header.display import (PYTEST_HEADER_MODULES, TESTED_VERSIONS) ASTROPY_HEADER = True except ImportError: ASTROPY_HEADER = False def pytest_configure(config): if ASTROPY_HEADER: config.option.astropy_header = True # Customize the following lines to add/remove entries from the # list of packages for which version numbers are displayed when # running the tests. PYTEST_HEADER_MODULES.clear() deps = ['NumPy', 'SciPy', 'Matplotlib', 'Astropy', 'Regions', 'skimage', 'GWCS', 'Bottleneck', 'tqdm', 'Rasterio', 'Shapely'] for dep in deps: PYTEST_HEADER_MODULES[dep] = dep.lower() from photutils import __version__ TESTED_VERSIONS['photutils'] = __version__ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6969266 photutils-2.2.0/photutils/datasets/0000755000175100001660000000000014755160634017027 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/datasets/__init__.py0000644000175100001660000000107714755160622021142 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains 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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6969266 photutils-2.2.0/photutils/datasets/data/0000755000175100001660000000000014755160634017740 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/datasets/data/100gaussians_params.ecsv0000644000175100001660000003170214755160622024403 0ustar00runnerdocker# %ECSV 1.0 # --- # datatype: # - {name: flux, datatype: float64} # - {name: x_mean, datatype: float64} # - {name: y_mean, datatype: float64} # - {name: x_stddev, datatype: float64} # - {name: y_stddev, datatype: float64} # - {name: theta, datatype: float64} # - {name: amplitude, datatype: float64} # schema: astropy-2.0 flux x_mean y_mean x_stddev y_stddev theta amplitude 964.808046408574 97.89749954439819 254.08289907307312 2.1843520590544467 4.675823444033835 1.439513292108698 15.034199467407237 658.1877772908929 147.25066097855222 113.42986110887117 4.194060236863709 1.365066882227572 1.1690269821419244 18.29706161768696 591.9594058385472 313.4999402859645 129.739534929611 2.179113171749347 1.2843284999601239 3.1994697250215594 33.66325845987719 602.2801392765199 43.11155256137605 249.78575288223342 3.7884863733341096 2.6548465445442204 3.333215245206706 9.530452502164367 783.8625145408433 71.47250990610482 111.33958070825817 2.091836915003706 1.8625646771453428 1.775874730526394 32.01996425196006 797.7723514896257 257.91325961546585 12.165981577848662 4.650113805061629 3.0787945261988345 1.948802401816166 8.86859504931396 982.2572598678108 344.670664829149 166.40144579159607 2.1941569350396053 3.234056088514339 1.9470609590238852 22.030793741353982 826.5885484357855 428.3129053949444 135.37387284327605 4.559439088414679 2.8948006556921224 1.6737707338720813 9.967343730088695 874.4533187669559 323.6808416610555 217.59029301801934 4.786889175058894 1.8634884866370744 3.7286378879812636 15.601870730148258 826.7849354258676 290.80933776536995 113.53515669437868 1.6756512914843973 1.957755541198448 1.3642954987175926 40.1116547978098 873.857404635637 355.55797754922986 252.19874871267191 2.4371656229305385 1.866953010885965 2.528246436214826 30.566257767153886 980.6533680364107 126.20842852326241 140.7944080750873 3.7287092114849343 3.622761512536076 1.8130837402407098 11.554133867514777 504.1941489707767 450.07984174130377 168.7930287036521 4.6855678802201695 4.385573801760902 1.0922740560175965 3.905074065981485 553.2221883488596 221.146846453142 198.35980177351772 1.5796973759771369 4.721857573264005 1.4181998931581699 11.804101683061127 649.3518568846907 10.260412328356006 138.67256025740141 1.3994975398327223 4.201222068518245 6.211817229004703 17.577311164422643 828.2055915411349 479.8305069984079 187.09108364661495 2.646411106286612 3.722903380921028 2.7272250830462004 13.378863232837679 904.9062762704674 326.1127112238628 66.56418935560802 4.900501483031972 1.8290278327296852 5.20366669065857 16.06803957862391 936.0879568621415 256.60312505233685 219.8589350265597 1.1660261635947395 3.966638899894948 3.5972042138385207 32.211120137727924 982.3237986859651 341.17819156885196 114.50462660879876 1.7476571780499608 3.787618342783211 4.096010251557252 23.618501574731145 861.8426734599767 244.77019531458555 58.45036253116934 3.4350719519841926 2.5270378970335607 4.585080060663453 15.801580661449423 821.2376639479206 463.2450856287161 81.34883243819053 1.3488748604925718 4.400476498565697 2.458507687663199 22.020015688113233 858.7268104062068 257.9398860843252 74.76751558884376 2.265152615647813 3.112444724438511 2.4684276148447215 19.385456768074707 733.7995036096482 36.07994086846561 45.641718676639975 3.440543106028315 4.572036275950712 5.400719661384855 7.424391661266191 662.792338769689 283.7541490995318 231.4121112603979 1.8047788515925212 4.282237499581191 5.089503319644426 13.649063927454758 719.8223029424048 307.62159186187216 76.62351970919306 3.767590865711192 1.9143144776011658 5.256160243159488 15.884312083467421 864.8445413734257 470.77314723482004 38.263021514560336 1.9785078847694324 4.282495877005472 1.1811629636530672 16.245139362737532 997.007292949981 207.68167736050148 199.55014599680814 3.721486783580557 1.5542457005465353 2.25941299990603 27.43356898820013 838.436855886885 132.2199872877417 123.84148460399483 2.9437509156395083 2.9298699806967528 3.0283946807155906 15.47180862160526 895.4112589238944 48.696582682889286 200.33032993166518 2.0419792466819873 1.5952422256903827 6.070013455692302 43.7486566285601 585.4571288962172 242.92211093900085 197.94405512939792 2.130961276866716 2.79317621972722 3.5394011204605853 15.654576974470553 513.4246378949678 232.33143143047653 91.61330156324993 4.621806479308752 3.663825889615503 5.199366394295275 4.825588144350849 900.1851219627304 14.879658495571102 60.367272308625076 2.1033707497538505 4.156129700731245 0.47819821649968547 16.388795648473245 951.8612691085 347.1387309286877 66.60813794973778 4.364592170031081 3.648083032368938 3.4754682061251727 9.514487585185533 512.3381052146326 358.473556182613 35.99125900317928 1.7856974987427723 3.846049715440203 3.4428114459495074 11.872823326701624 745.8736592229429 364.90571164558474 11.143632309079187 2.3223337178578407 4.5516946485244265 5.689069910871459 11.230204233884345 763.1275836749289 207.17550859477285 10.239475057235781 4.786519540733119 1.5667011649886549 2.3215658114967512 16.19613099468567 798.1830052096902 7.549422397079509 69.14896437436673 3.024287398154893 1.5902804942054503 5.662171871226542 26.413491719398397 525.9787725512668 454.48757874207365 68.50611193964927 2.613663433181723 2.3037233717997245 0.8836107895915326 13.90299474776872 947.5447640269606 394.68935905518 187.47318657744282 1.1082461535074435 1.1274953181946978 0.6231268937198444 120.68933322480586 864.1330901635587 82.59958458158827 267.76835564002835 3.4870947577334044 3.751051578465331 6.114690760677037 10.514387597168403 909.1750056949572 156.39298066313552 233.9184048728827 2.3893940270293155 4.058920180960568 5.919043430533304 14.920017581761048 750.1113764172242 305.472652909798 216.43538058842236 2.107196911218392 2.4000376746989676 3.713029257899366 23.606016353615153 905.0947044016274 182.24514335942953 93.13264771057578 1.256219095731597 3.4640304426724273 0.32646089076436025 33.102977945937276 547.9842628722834 78.01929460074975 108.9250249269539 2.337562075662724 4.833999435269771 0.31511241020444675 7.718243397964587 609.4750218660467 88.6519067122759 58.82453515928686 1.2708397698599714 2.1642104664534423 2.039535629643309 35.26839961391395 629.3595308015563 433.944835523523 280.64753941010434 1.0152969930519142 3.746713850016325 2.36090538291161 26.3314829937531 734.0528769764676 145.04733422224675 168.5202000528061 1.1638712136579428 1.1689129211278324 4.801291776153723 85.873742531488 729.6866013017404 292.5898106573307 245.18746106721326 2.3958194624918416 1.2500145268809866 5.398926522671802 38.778173963780226 854.7548901045895 226.9974379547345 104.6741942841017 1.3678172433456401 4.257594782805457 3.42878621028271 23.35981109171799 589.0265029417691 205.5890660862694 239.91427884708384 3.081262905835392 2.7464734497011043 2.034840871894464 11.077730797034372 765.7249421798169 441.31722258842086 31.231338758202487 1.8629992535288005 2.568273728192377 2.5498100113961435 25.470585905334087 583.8711143932428 346.35400739737446 213.63604953821843 4.966666180423694 2.322590718653218 0.6890629410239101 8.055629142620626 884.4069592029051 139.63667759787523 275.23454877469794 2.068748644205795 2.830635313662304 0.8626267857146525 24.037020619488107 964.0852745576614 32.22011559264176 241.07561058875962 3.720672210165289 4.341408609098313 4.683417017383964 9.499122918678843 804.7468289967518 99.31180687942448 98.13394388968734 3.1049844766732857 2.867075504798007 5.506681247335922 14.387351105520032 575.0917473344408 465.8413723514779 76.53215377915976 2.7596922789218654 2.685143951089968 1.6272780372572588 12.35176542536098 744.8133518469976 427.2067838903452 148.50065230714134 3.646220982904549 3.2834062117540923 4.307762282494921 9.90147701356685 688.6724769166128 477.3673672804626 124.08328628084001 1.5391406984146236 3.9562238267733205 2.256363946629663 18.000048775619845 924.3007059888894 26.12667412158648 127.48112858782564 4.151396019636208 1.2203336343762956 0.03780671426767416 29.037596795647307 955.5486143158364 289.735840295467 22.31349145343068 1.5519108040495366 3.496722596287366 4.782640377951582 28.024958752672504 691.9243605786443 240.2481333245647 181.0171953868571 3.531693190918051 3.484241310776869 2.6013794191971553 8.949268929760564 657.7479516980619 10.854489487782114 224.15992006364175 1.8546614308021399 1.0044421272766613 2.0979051470175456 56.194016706706975 784.197076397712 186.81023186719747 239.272867998246 1.093501258932902 3.4115740256064413 1.74699621435307 33.45578803411642 593.9090175121999 207.0459005882796 114.45015676613785 3.5077358931239027 1.1607416483960464 3.72287625728951 23.215476215111874 562.9207719136851 301.95361695700217 239.14744584612285 3.568654906280811 3.3430014671307333 2.0426675786104207 7.509763942358084 843.797902522246 335.87436367761876 141.53921088425687 3.5684133471288484 2.793517698653144 5.8861215543037835 13.471997414115208 899.8033589737009 419.43285021915824 216.41975134338938 4.458483598696862 2.4149210704756774 0.6030788209984189 13.30079584688517 786.7682825993215 389.763104159437 56.76380678235072 2.3488056138657467 4.734100937496935 5.54991085245916 11.261140723532716 986.6149907997425 200.35052202061559 130.37871281388303 2.643833546992447 2.069023727033364 3.568960673583446 28.70570791636843 817.0271885687393 397.2646156948415 247.04043271564257 3.4412747351003192 2.125197521855424 0.38907666538334523 17.78025535040455 944.2108624057134 446.5621551988015 247.88178835953084 4.763372762954965 3.019623854582616 5.497715925124123 10.447724388607257 747.7073793787322 131.24484542944575 56.85764504448847 1.6083090791701964 3.587224474702067 1.4127916029921659 20.62641402432222 675.8082649451061 494.59850368905296 6.03065104579894 1.7362320240392695 4.8822937160946465 5.022673288427523 12.688549121064984 857.1151842722603 426.65354950786895 211.04741581679082 1.6177531527840023 1.1435019175795258 5.038329110827512 73.74119453584417 751.9645582239577 365.7385780188785 104.18103582568175 3.851297303399289 2.6927994645723836 0.181926612470949 11.540016920517292 612.8188032603198 177.78281235317678 125.85114486703607 4.711932645384994 3.3532483546799967 3.9401792447894834 6.172874110334187 622.4872201209321 441.64474535366827 230.4266709533156 2.6907487199266913 3.9268091302219332 3.7990605196329814 9.37643261476075 896.4003500247952 433.97954541354267 288.8634198912861 3.180709054596129 1.7020442916714096 0.49139237108100303 26.352835222969194 747.5862072556845 477.88322291681897 267.76959207766816 2.2895558612877362 3.0421551384372796 4.856610468324372 17.08239697369921 957.5468366998882 0.05362825986848785 40.48268000221009 1.6428187329380126 3.0011383833576275 0.9004849078854927 30.910390365430022 972.6859169375089 8.270520959487326 239.34141449762154 3.8126080983661867 4.571070527911052 2.9134163150744063 8.882857014999784 766.6161148276078 157.03502862937202 204.62718226211888 3.9209936256680225 2.5307308300222147 0.6101643723612765 12.29577716139544 626.2462972787133 497.65808729635785 159.01586613632531 4.841618150435656 3.6514553472118823 3.5804996367196473 5.637788431860726 860.4310290874239 74.42211157799528 259.7944965618501 2.4819622830675323 1.1872462388082012 2.536942619414388 46.4729472143669 683.7193818972887 83.68855935425218 225.9744286058185 3.866793567273198 4.004893135526702 3.685582699041082 7.026775905598758 749.3242214555266 379.28601845895963 27.960776045775393 4.944244871298163 2.475141169343062 4.704260892024514 9.745182062641872 613.2875237312279 34.76483307716155 98.83182960947107 1.4430702347055497 4.792114546247898 5.589608564767361 14.114633381563364 676.7828233730235 352.73671929813526 123.7088086898568 4.032914195635907 2.3889008726968637 2.3837786502572786 11.180271457013728 825.4258933116027 234.58264088954778 0.28938232983252155 1.6302164853414487 3.7355727953878306 1.837431223348704 21.572264447869767 656.466447652576 5.094108062656422 179.26906893808953 4.265741021633359 4.298539526981184 3.1999649528919267 5.697931916427482 884.3677235846699 387.41193143313103 204.76846262476076 3.2164261849098135 2.9450119769447682 3.347766585491736 14.859095063368128 890.9185516856271 397.100504180237 109.88422846305845 3.326840883437651 4.936703485305969 0.6623835781893743 8.633543097902201 926.2047414703625 74.78472587293278 118.94965722809343 4.744582332803316 3.4152561526530203 0.28865583791802196 9.097160601251925 974.9528700759936 11.85181589504447 142.60774365339384 2.737391020406234 4.038499327940802 3.908098216163365 14.036114441245825 553.6614561005221 381.03188544719495 174.32397016297364 2.9565087454722323 3.7380667814650073 5.167573851519573 7.973301713571764 955.3626779827014 111.83508994667218 41.14113834322911 3.6645218672230655 4.729664908728548 0.34597461338954427 8.772850180362555 668.0275809562431 131.0871988825571 299.8242520392146 3.520616004328165 4.799141886114043 5.013815027029375 6.292631158272047 913.1902134073223 228.43475138774744 152.19816263495727 1.520819002612627 4.9604445122118825 1.534636103923281 19.26563170823333 949.0503175599234 124.9634592535091 147.92001136372195 2.415375884928947 1.504621532348117 5.048776534358012 41.56208936719848 521.3576521681665 284.1417807953734 56.06242650836353 3.5404935385444953 4.906402469007675 1.8734891902803512 4.776710351881127 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/datasets/data/4gaussians_params.ecsv0000644000175100001660000000071514755160622024246 0ustar00runnerdocker# %ECSV 1.0 # --- # datatype: # - {name: amplitude, datatype: int64} # - {name: x_mean, datatype: int64} # - {name: y_mean, datatype: int64} # - {name: x_stddev, datatype: float64} # - {name: y_stddev, datatype: float64} # - {name: theta, datatype: float64} # schema: astropy-2.0 amplitude x_mean y_mean x_stddev y_stddev theta 50 160 70 15.2 2.6 2.530727415391778 70 25 40 5.1 2.5 0.3490658503988659 150 150 25 3.0 3.0 0.0 210 90 60 8.1 4.7 1.0471975511965976 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/datasets/examples.py0000644000175100001660000000632514755160622021222 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for making simulated example images for documentation examples and tests. """ import pathlib import numpy as np from astropy.modeling.models import Gaussian2D from astropy.table import QTable from astropy.utils.data import get_pkg_data_path from photutils.datasets import make_model_image __all__ = ['make_4gaussians_image', 'make_100gaussians_image'] _DATASETS_DATA_DIR = pathlib.Path(get_pkg_data_path('datasets', 'data', package='photutils')) def make_4gaussians_image(noise=True): """ Make an example image containing four 2D Gaussians plus a constant background. The background has a mean of 5. If ``noise`` is `True`, then Gaussian noise with a mean of 0 and a standard deviation of 5 is added to the output image. Parameters ---------- noise : bool, optional Whether to include noise in the output image (default is `True`). Returns ------- image : 2D `~numpy.ndarray` Image containing four 2D Gaussian sources. See Also -------- make_100gaussians_image Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import make_4gaussians_image image = make_4gaussians_image() plt.imshow(image, origin='lower', interpolation='nearest') """ shape = (100, 200) model = Gaussian2D() params = QTable.read(_DATASETS_DATA_DIR / '4gaussians_params.ecsv', format='ascii.ecsv') data = make_model_image(shape, model, params, x_name='x_mean', y_name='y_mean') data += 5.0 # background if noise: rng = np.random.default_rng(seed=0) data += rng.normal(loc=0.0, scale=5.0, size=shape) return data def make_100gaussians_image(noise=True): """ Make an example image containing 100 2D Gaussians plus a constant background. The background has a mean of 5. If ``noise`` is `True`, then Gaussian noise with a mean of 0 and a standard deviation of 2 is added to the output image. Parameters ---------- noise : bool, optional Whether to include noise in the output image (default is `True`). Returns ------- image : 2D `~numpy.ndarray` Image containing 100 2D Gaussian sources. See Also -------- make_4gaussians_image Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import make_100gaussians_image image = make_100gaussians_image() plt.imshow(image, origin='lower', interpolation='nearest') """ shape = (300, 500) model = Gaussian2D() params = QTable.read(_DATASETS_DATA_DIR / '100gaussians_params.ecsv', format='ascii.ecsv') data = make_model_image(shape, model, params, bbox_factor=6.0, x_name='x_mean', y_name='y_mean') data += 5.0 # background if noise: rng = np.random.default_rng(seed=0) data += rng.normal(loc=0.0, scale=2.0, size=shape) return data ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/datasets/images.py0000644000175100001660000003711314755160622020650 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for making simulated images for documentation examples and tests. """ import astropy.units as u import numpy as np from astropy.convolution import discretize_model from astropy.modeling import Model from astropy.nddata.utils import NoOverlapError from astropy.table import Table from photutils.utils._parameters import as_pair from photutils.utils._progress_bars import add_progress_bar from photutils.utils.cutouts import _overlap_slices as overlap_slices __all__ = ['make_model_image'] def make_model_image(shape, model, params_table, *, model_shape=None, bbox_factor=None, x_name='x_0', y_name='y_0', params_map=None, discretize_method='center', discretize_oversample=10, progress_bar=False): """ Make a 2D image containing sources generated from a user-specified astropy 2D model. The model parameters for each source are taken from the input ``params_table`` table. By default, the table is searched for column names that match model parameter names and the values specified by ``x_name`` and ``y_name``. However, the user can specify a different mapping between model parameter names and column names using the ``params_map`` keyword. Parameters ---------- shape : 2-tuple of int The shape of the output image. model : 2D `astropy.modeling.Model` The 2D model to be used to render the sources. The model must be two-dimensional where it accepts 2 inputs (i.e., (x, y)) and has 1 output. The model must have parameters for the x and y positions of the sources. Typically, these parameters are named 'x_0' and 'y_0', but the parameter names can be specified using the ``x_name`` and ``y_name`` keywords. params_table : `~astropy.table.Table` A table containing the model parameters for each source. Each row of the table corresponds to a source whose model parameters are defined by the column names, which must match the model parameter names. The table must contain columns for the x and y positions of the sources. The column names for the x and y positions can be specified using the ``x_name`` and ``y_name`` keywords. Model parameters not defined in the table or ``params_maps`` will be set to the ``model`` default value. To attach units to model parameters, ``params_table`` must be input as a `~astropy.table.QTable`. If the table contains a column named 'model_shape', then the values in that column will be used to override the ``model_shape`` keyword and model ``bounding_box`` for each source. This can be used to render each source with a different shape. If the table contains a column named 'local_bkg', then the per-pixel local background values in that column will be used to added to each model source over the region defined by its ``model_shape``. The 'local_bkg' column must have the same flux units as the output image (e.g., if the input ``model`` has 'amplitude' or 'flux' parameters with units). Including 'local_bkg' should be used with care, especially in crowded fields where the ``model_shape`` of sources overlap (see Notes below). Except for ``model_shape`` and ``local_bkg`` column names, column names that do not match model parameters will be ignored unless ``params_map`` is input. model_shape : 2-tuple of int, int, or `None`, optional The shape around the (x, y) center of each source that will used to evaluate the ``model``. If ``model_shape`` is a scalar integer, then a square shape of size ``model_shape`` will be used. If `None`, then the bounding box of the model will be used (which can optionally be scaled using the ``bbox_factor`` keyword if the model supports it). This keyword must be specified if the model does not have a ``bounding_box`` attribute. If specified, this keyword overrides the model ``bounding_box`` attribute. To use a different shape for each source, include a column named ``'model_shape'`` in the ``params_table``. For that case, this keyword is ignored. bbox_factor : `None` or float, optional The multiplicative factor to pass to the model ``bounding_box`` method to determine the model shape. If the model ``bounding_box`` method does not accept a ``factor`` keyword, then this keyword is ignored. If `None`, the default model bounding box will be used. This keyword is ignored if ``model_shape`` is specified or if the ``params_table`` contains a ``'model_shape'`` column. Note that some Photutils PSF models have a ``bbox_factor`` keyword that is be used to define the model bounding box. In that case, this keyword is ignored. x_name : str, optional The name of the ``model`` parameter that corresponds to the x position of the sources. If ``param_map`` is not input, then this value must also be a column name in ``params_table``. y_name : str, optional The name of the ``model`` parameter that corresponds to the y position of the sources. If ``param_map`` is not input, then this value must also be a column name in ``params_table``. params_map : dict or None, optional A dictionary mapping the model parameter names to the column names in the input ``params_table``. The dictionary keys are the model parameter names and the values are the column names in the input ``params_table``. This can be used to map column names to model parameter names that are different. For example, if the input column name is 'flux_f200w' and the model parameter name is 'flux', then use ``column_map={'flux': 'flux_f200w'}``. This table may also be used if you want to map the model x and y parameters to different columns than ``x_name`` and ``y_name``, but the ``x_name`` and ``y_name`` keys must be included in the dictionary. discretize_method : {'center', 'interp', 'oversample', 'integrate'}, \ optional One of the following methods for discretizing the model on the pixel grid: * ``'center'`` (default) Discretize model by taking the value at the center of the pixel bins. This method should be used for ePSF/PRF single or gridded models. * ``'interp'`` Discretize model by bilinearly interpolating between the values at the corners of the pixel bins. * ``'oversample'`` Discretize model by taking the average of model values in the pixel bins on an oversampled grid. Use the ``discretize_oversample`` keyword to set the integer oversampling factor. * ``'integrate'`` Discretize model by integrating the model over the pixel bins using `scipy.integrate.quad`. This mode conserves the model integral on a subpixel scale, but it is *extremely* slow. discretize_oversample : int, optional The integer oversampling factor used when ``descretize_method='oversample'``. This keyword is ignored otherwise. progress_bar : bool, optional Whether to display a progress bar while adding the sources to the image. The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. Returns ------- array : 2D `~numpy.ndarray` The rendered image containing the model sources. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from astropy.modeling.models import Moffat2D from photutils.datasets import (make_model_image, make_random_models_table) model = Moffat2D() n_sources = 25 shape = (100, 100) param_ranges = {'amplitude': [100, 200], 'x_0': [0, shape[1]], 'y_0': [0, shape[0]], 'gamma': [1, 2], 'alpha': [1, 2]} params = make_random_models_table(n_sources, param_ranges, seed=0) model_shape = (15, 15) data = make_model_image(shape, model, params, model_shape=model_shape) plt.imshow(data, origin='lower') plt.tight_layout() .. plot:: :include-source: import matplotlib.pyplot as plt 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') plt.imshow(data, origin='lower') plt.tight_layout() Notes ----- The local background value around each source is optionally included using the ``local_bkg`` column in the input ``params_table``. This local background added to each source over its ``model_shape`` region. In regions where the ``model_shape`` of source overlap, the local background will be added multiple times. This is not an issue if the sources are well-separated, but for crowded fields, this option should be used with care. """ if not isinstance(shape, tuple) or len(shape) != 2: raise ValueError('shape must be a 2-tuple') if not isinstance(model, Model): raise TypeError('model must be a Model instance') if model.n_inputs != 2 or model.n_outputs != 1: raise ValueError('model must be a 2D model') if not isinstance(params_table, Table): raise TypeError('params_table must be an astropy Table') 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: raise ValueError(f'key "{key}" not in model parameter names') if value not in params_table.colnames: raise ValueError(f'value "{value}" not in params_table column ' 'names') if model_shape is not None: model_shape = as_pair('model_shape', model_shape, lower_bound=(0, 1)) variable_shape = False if 'model_shape' in params_table.colnames: model_shape = np.array(params_table['model_shape']) if model_shape.ndim == 1: model_shape = np.array([as_pair('model_shape', shape) for shape in model_shape]) variable_shape = True if model_shape is None: try: _ = model.bounding_box except NotImplementedError as exc: raise ValueError('model_shape must be specified if the model ' 'does not have a bounding_box attribute') from exc if 'local_bkg' in params_table.colnames: local_bkg = params_table['local_bkg'] else: local_bkg = np.zeros(len(params_table)) # copy the input model to leave it unchanged model = model.copy() if progress_bar: # pragma: no cover desc = 'Add model sources' params_table = add_progress_bar(params_table, desc=desc) image = np.zeros(shape, dtype=float) for i, source in enumerate(params_table): for key, param in params_map.items(): setattr(model, key, source[param]) # This assumes that if the user also uses params_table to # override the (x/y)_name mapping that the x_name and y_name # values are correct (i.e., the mapping keys include x_name and # y_name). There is no good way to check/enforce this. x0 = getattr(model, x_name).value y0 = getattr(model, y_name).value if variable_shape: mod_shape = model_shape[i] elif model_shape is None: # the bounding box size generally depends on model parameters, # so needs to be calculated for each source mod_shape = _model_shape_from_bbox(model, bbox_factor=bbox_factor) else: mod_shape = model_shape try: slc_lg, _ = overlap_slices(shape, mod_shape, (y0, x0), mode='trim') if discretize_method == 'center': yy, xx = np.mgrid[slc_lg] subimg = model(xx, yy) else: if discretize_method == 'interp': discretize_method = 'linear_interp' x_range = (slc_lg[1].start, slc_lg[1].stop) y_range = (slc_lg[0].start, slc_lg[0].stop) subimg = discretize_model(model, x_range=x_range, y_range=y_range, mode=discretize_method, factor=discretize_oversample) if i == 0 and isinstance(subimg, u.Quantity): image <<= subimg.unit try: image[slc_lg] += subimg + local_bkg[i] except u.UnitConversionError as exc: raise ValueError('The local_bkg column must have the same ' 'flux units as the output image') from exc except NoOverlapError: continue 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 used to evaluate the model. Raises ------ ValueError If the model does not have a bounding_box attribute. """ try: hasattr(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]))) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/datasets/load.py0000644000175100001660000002124214755160622020316 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for loading example datasets, from both within photutils and remote servers. """ from urllib.error import HTTPError, URLError from astropy.io import fits from astropy.table import Table from astropy.utils.data import download_file, get_pkg_data_filename __all__ = [ 'get_path', 'load_irac_psf', 'load_simulated_hst_star_image', 'load_spitzer_catalog', 'load_spitzer_image', 'load_star_image', ] def get_path(filename, location='local', cache=True, show_progress=False): """ Get the local path for a given file. Parameters ---------- filename : str File name in the local or remote data folder. location : {'local', 'remote', 'photutils-datasets'} File location. ``'local'`` means bundled with ``photutils``. ``'remote'`` means the astropy data server (or the photutils-datasets repo as a backup) or the Astropy cache on your machine. ``'photutils-datasets'`` means the photutils-datasets repo or the Astropy cache on your machine. cache : bool, optional Whether to cache the contents of remote URLs. Default is `True`. show_progress : bool, optional Whether to display a progress bar during the download (default is `False`). The progress bar is displayed only when outputting to a terminal. Returns ------- path : str The local path of the file. """ datasets_url = ('https://github.com/astropy/photutils-datasets/raw/' f'main/data/{filename}') if location == 'local': path = get_pkg_data_filename('data/' + filename) elif location == 'remote': # pragma: no cover try: url = f'https://data.astropy.org/photometry/{filename}' path = download_file(url, cache=cache, show_progress=show_progress) except (URLError, HTTPError): # timeout or not found path = download_file(datasets_url, cache=cache, show_progress=show_progress) elif location == 'photutils-datasets': # pragma: no cover path = download_file(datasets_url, cache=cache, show_progress=show_progress) else: raise ValueError(f'Invalid location: {location}') return path def load_spitzer_image(show_progress=False): # pragma: no cover """ Load a 4.5 micron Spitzer image. The catalog for this image is returned by :func:`load_spitzer_catalog`. Parameters ---------- show_progress : bool, optional Whether to display a progress bar during the download (default is `False`). Returns ------- hdu : `~astropy.io.fits.ImageHDU` The 4.5 micron Spitzer image in a FITS image HDU. See Also -------- load_spitzer_catalog Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import load_spitzer_image hdu = load_spitzer_image() plt.imshow(hdu.data, origin='lower', vmax=50) """ path = get_path('spitzer_example_image.fits', location='remote', show_progress=show_progress) with fits.open(path) as hdulist: data = hdulist[0].data header = hdulist[0].header return fits.ImageHDU(data, header) def load_spitzer_catalog(show_progress=False): # pragma: no cover """ Load a 4.5 micron Spitzer catalog. The image from which this catalog was derived is returned by :func:`load_spitzer_image`. Parameters ---------- show_progress : bool, optional Whether to display a progress bar during the download (default is `False`). Returns ------- catalog : `~astropy.table.Table` The catalog of sources. See Also -------- load_spitzer_image Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import load_spitzer_catalog catalog = load_spitzer_catalog() plt.scatter(catalog['l'], catalog['b']) plt.xlabel('Galactic l') plt.ylabel('Galactic b') plt.xlim(18.39, 18.05) plt.ylim(0.13, 0.30) """ path = get_path('spitzer_example_catalog.xml', location='remote', show_progress=show_progress) return Table.read(path) def load_irac_psf(channel, show_progress=False): # pragma: no cover """ Load a Spitzer IRAC PSF image. Parameters ---------- channel : int (1-4) The IRAC channel number: * Channel 1: 3.6 microns * Channel 2: 4.5 microns * Channel 3: 5.8 microns * Channel 4: 8.0 microns show_progress : bool, optional Whether to display a progress bar during the download (default is `False`). Returns ------- hdu : `~astropy.io.fits.ImageHDU` The IRAC PSF in a FITS image HDU. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from astropy.visualization import ImageNormalize, LogStretch from photutils.datasets import load_irac_psf hdu1 = load_irac_psf(1) hdu2 = load_irac_psf(2) hdu3 = load_irac_psf(3) hdu4 = load_irac_psf(4) norm = ImageNormalize(hdu1.data, stretch=LogStretch()) fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2) ax1.imshow(hdu1.data, origin='lower', interpolation='nearest', norm=norm) ax1.set_title('IRAC Ch1 PSF') ax2.imshow(hdu2.data, origin='lower', interpolation='nearest', norm=norm) ax2.set_title('IRAC Ch2 PSF') ax3.imshow(hdu3.data, origin='lower', interpolation='nearest', norm=norm) ax3.set_title('IRAC Ch3 PSF') ax4.imshow(hdu4.data, origin='lower', interpolation='nearest', norm=norm) ax4.set_title('IRAC Ch4 PSF') plt.tight_layout() plt.show() """ channel = int(channel) if channel < 1 or channel > 4: raise ValueError('channel must be 1, 2, 3, or 4') filepath = f'irac_ch{channel}_flight.fits' path = get_path(filepath, location='remote', show_progress=show_progress) with fits.open(path) as hdulist: data = hdulist[0].data header = hdulist[0].header return fits.ImageHDU(data, header) def load_star_image(show_progress=False): # pragma: no cover """ Load an optical image of stars. This is an image of M67 from photographic data obtained as part of the National Geographic Society - Palomar Observatory Sky Survey (NGS-POSS). The image was digitized from the POSS-I Red plates as part of the Digitized Sky Survey produced at the Space Telescope Science Institute. Parameters ---------- show_progress : bool, optional Whether to display a progress bar during the download (default is `False`). Returns ------- hdu : `~astropy.io.fits.ImageHDU` The M67 image in a FITS image HDU. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import load_star_image hdu = load_star_image() plt.imshow(hdu.data, origin='lower', interpolation='nearest') """ path = get_path('M6707HH.fits', location='remote', show_progress=show_progress) with fits.open(path) as hdulist: data = hdulist[0].data header = hdulist[0].header return fits.ImageHDU(data, header) def load_simulated_hst_star_image(show_progress=False): # pragma: no cover """ Load a simulated HST WFC3/IR F160W image of stars. The simulated image does not contain any background or noise. Parameters ---------- show_progress : bool, optional Whether to display a progress bar during the download (default is `False`). Returns ------- hdu : `~astropy.io.fits.ImageHDU` A FITS image HDU containing the simulated HST star image. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import load_simulated_hst_star_image hdu = load_simulated_hst_star_image() plt.imshow(hdu.data, origin='lower', interpolation='nearest') """ path = get_path('hst_wfc3ir_f160w_simulated_starfield.fits', location='photutils-datasets', show_progress=show_progress) with fits.open(path) as hdulist: data = hdulist[0].data header = hdulist[0].header return fits.ImageHDU(data, header) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/datasets/model_params.py0000644000175100001660000002576614755160622022061 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for making tables of model parameters or making models from a table of model parameters. """ import numpy as np from astropy.table import QTable from photutils.utils._coords import make_random_xycoords from photutils.utils._misc import _get_meta from photutils.utils._parameters import as_pair __all__ = ['make_model_params', 'make_random_models_table', 'params_table_to_models'] def make_model_params(shape, n_sources, *, x_name='x_0', y_name='y_0', min_separation=1, border_size=(0, 0), seed=0, **kwargs): """ Make a table of randomly generated model positions and additional parameters for simulated sources. By default, this function computes only a table of x_0 and y_0 values. Additional parameters can be specified as keyword arguments with their lower and upper bounds as 2-tuples. The parameter values will be uniformly distributed between the lower and upper bounds, inclusively. Parameters ---------- shape : 2-tuple of int The shape of the output image. n_sources : int The number of sources to generate. If ``min_separation`` is too large, the number of requested sources may not fit within the given ``shape`` and therefore the number of sources generated may be less than ``n_sources``. x_name : str, optional The name of the ``model`` parameter that corresponds to the x position of the sources. This will be the column name in the output table. y_name : str, optional The name of the ``model`` parameter that corresponds to the y position of the sources. This will be the column name in the output table. min_separation : float, optional The minimum separation between the centers of two sources. Note that if the minimum separation is too large, the number of sources generated may be less than ``n_sources``. border_size : tuple of 2 int or int, optional The (ny, nx) size of the border around the image where no sources will be generated (i.e., the source center will not be located within the border). If a single integer is provided, it will be used for both dimensions. seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. **kwargs Keyword arguments are accepted for additional model parameters. The values should be 2-tuples of the lower and upper bounds for the parameter range. The parameter values will be uniformly distributed between the lower and upper bounds, inclusively. Returns ------- table : `~astropy.table.QTable` A table containing the model parameters of the generated sources. The table will also contain an ``'id'`` column with unique source IDs. Examples -------- >>> from photutils.datasets import make_model_params >>> params = make_model_params((100, 100), 5, flux=(100, 500), ... min_separation=3, border_size=10, seed=0) >>> for col in params.colnames: ... params[col].info.format = '%.8g' # for consistent table output >>> print(params) id x_0 y_0 flux --- --------- --------- --------- 1 60.956935 72.967865 291.99517 2 31.582937 29.149555 192.94917 3 13.277882 80.118738 420.75223 4 11.322211 14.685443 469.41206 5 75.061619 36.889365 206.45211 >>> params = make_model_params((100, 100), 5, flux=(100, 500), ... x_name='x_mean', y_name='y_mean', ... min_separation=3, border_size=10, seed=0) >>> for col in params.colnames: ... params[col].info.format = '%.8g' # for consistent table output >>> print(params) id x_mean y_mean flux --- --------- --------- --------- 1 60.956935 72.967865 291.99517 2 31.582937 29.149555 192.94917 3 13.277882 80.118738 420.75223 4 11.322211 14.685443 469.41206 5 75.061619 36.889365 206.45211 >>> params = make_model_params((100, 100), 5, flux=(100, 500), ... sigma=(1, 2), alpha=(0, 1), ... min_separation=3, border_size=10, seed=0) >>> for col in params.colnames: ... params[col].info.format = '%.5g' # for consistent table output >>> print(params) id x_0 y_0 flux sigma alpha --- ------ ------ ------ ------ -------- 1 60.957 72.968 292 1.5389 0.61437 2 31.583 29.15 192.95 1.4428 0.028365 3 13.278 80.119 420.75 1.931 0.71922 4 11.322 14.685 469.41 1.0405 0.015992 5 75.062 36.889 206.45 1.732 0.75795 """ shape = as_pair('shape', shape, lower_bound=(0, 1)) border_size = as_pair('border_size', border_size, lower_bound=(0, 0)) xrange = (border_size[1], shape[1] - border_size[1]) yrange = (border_size[0], shape[0] - border_size[0]) if xrange[0] >= xrange[1] or yrange[0] >= yrange[1]: raise ValueError('border_size is too large for the given shape') rng = np.random.default_rng(seed) xycoords = make_random_xycoords(n_sources, xrange, yrange, min_separation=min_separation, seed=rng) x, y = np.transpose(xycoords) model_params = QTable() model_params['id'] = np.arange(len(x)) + 1 model_params[x_name] = x model_params[y_name] = y for param, prange in kwargs.items(): if len(prange) != 2: raise ValueError(f'{param} must be a 2-tuple') vals = rng.uniform(*prange, len(model_params)) model_params[param] = vals return model_params def make_random_models_table(n_sources, param_ranges, seed=None): """ Make a `~astropy.table.QTable` containing randomly generated parameters for an Astropy model to simulate a set of sources. Each row of the table corresponds to a source whose parameters are defined by the column names. The parameters are drawn from a uniform distribution over the specified input ranges, inclusively. The output table can be input into :func:`make_model_image` to create an image containing the model sources. Parameters ---------- n_sources : float The number of random model sources to generate. param_ranges : dict The lower and upper boundaries for each of the model parameters as a dictionary mapping the parameter name to its ``(lower, upper)`` bounds. The parameter values will be uniformly distributed between these bounds, inclusively. seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Returns ------- table : `~astropy.table.QTable` A table of parameters for the randomly generated sources. Each row of the table corresponds to a source whose model parameters are defined by the column names. The column names will be the keys of the dictionary ``param_ranges``. The table will also contain an ``'id'`` column with unique source IDs. Notes ----- To generate identical parameter values from separate function calls, ``param_ranges`` must have the same parameter ranges and the ``seed`` must be the same. Examples -------- >>> from photutils.datasets import make_random_models_table >>> n_sources = 5 >>> param_ranges = {'amplitude': [500, 1000], ... 'x_mean': [0, 500], ... 'y_mean': [0, 300], ... 'x_stddev': [1, 5], ... 'y_stddev': [1, 5], ... 'theta': [0, np.pi]} >>> params = make_random_models_table(n_sources, param_ranges, seed=0) >>> for col in params.colnames: ... params[col].info.format = '%.8g' # for consistent table output >>> print(params) id amplitude x_mean y_mean x_stddev y_stddev theta --- --------- --------- ---------- --------- --------- --------- 1 818.48084 456.37779 244.75607 1.7026225 1.1132787 1.2053586 2 634.89336 303.31789 0.82155005 4.4527157 1.4971331 3.1328274 3 520.48676 364.74828 257.22128 3.1658449 3.6824977 3.0813851 4 508.26382 271.8125 10.075673 2.1988476 3.588758 2.1536937 5 906.63512 467.53621 218.89663 2.6907489 3.4615404 2.0434781 """ rng = np.random.default_rng(seed) sources = QTable() sources.meta.update(_get_meta()) # keep sources.meta type sources['id'] = np.arange(n_sources) + 1 for param_name, (lower, upper) in param_ranges.items(): # Generate a column for every item in param_ranges, even if it # is not in the model (e.g., flux). sources[param_name] = rng.uniform(lower, upper, n_sources) return sources def params_table_to_models(params_table, model): """ Create a list of models from a table of model parameters. Parameters ---------- params_table : `~astropy.table.Table` A table containing the model parameters for each source. Each row of the table corresponds to a different model whose parameters are defined by the column names. Model parameters not defined in the table will be set to the ``model`` default value. To attach units to model parameters, ``params_table`` must be input as a `~astropy.table.QTable`. A column named 'name' can also be included in the table to assign a name to each model. model : `astropy.modeling.Model` The model whose parameters will be updated. Returns ------- models : list of `astropy.modeling.Model` A list of models created from the input table of model parameters. Examples -------- >>> from astropy.table import QTable >>> from photutils.datasets import params_table_to_models >>> from photutils.psf import CircularGaussianPSF >>> tbl = QTable() >>> tbl['x_0'] = [1, 2, 3] >>> tbl['y_0'] = [4, 5, 6] >>> tbl['flux'] = [100, 200, 300] >>> model = CircularGaussianPSF() >>> models = params_table_to_models(tbl, model) >>> models [, , ] """ param_names = set(model.param_names) colnames = set(params_table.colnames) if param_names.isdisjoint(colnames): raise ValueError('No matching model parameter names found in ' 'params_table') param_names = [*list(param_names), 'name'] models = [] for row in params_table: new_model = model.copy() for param_name in param_names: if param_name not in colnames: continue setattr(new_model, param_name, row[param_name]) models.append(new_model) return models ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/datasets/noise.py0000644000175100001660000001117414755160622020517 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for including noise in simulated data. """ import numpy as np __all__ = ['apply_poisson_noise', 'make_noise_image'] def apply_poisson_noise(data, seed=None): """ Apply Poisson noise to an array, where the value of each element in the input array represents the expected number of counts. Each pixel in the output array is generated by drawing a random sample from a Poisson distribution whose expectation value is given by the pixel value in the input array. Parameters ---------- data : array_like The array on which to apply Poisson noise. Every pixel in the array must have a positive value (i.e., counts). seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Returns ------- result : `~numpy.ndarray` The data array after applying Poisson noise. See Also -------- make_noise_image Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import (apply_poisson_noise, make_4gaussians_image) data1 = make_4gaussians_image(noise=False) data2 = apply_poisson_noise(data1, seed=0) # plot the images fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 8)) ax1.imshow(data1, origin='lower', interpolation='nearest') ax1.set_title('Original image') ax2.imshow(data2, origin='lower', interpolation='nearest') ax2.set_title('Original image with Poisson noise applied') """ data = np.asanyarray(data) if np.any(data < 0): raise ValueError('data must not contain any negative values') rng = np.random.default_rng(seed) return rng.poisson(data) def make_noise_image(shape, distribution='gaussian', mean=None, stddev=None, seed=None): r""" Make a noise image containing Gaussian or Poisson noise. This function simply takes random samples from a Gaussian or Poisson distribution with the given parameters. If you want to apply Poisson noise to existing sources, see the `~photutils.datasets.apply_poisson_noise` function. Parameters ---------- shape : 2-tuple of int The shape of the output 2D image. distribution : {'gaussian', 'poisson'} The distribution used to generate the random noise: * ``'gaussian'``: Gaussian distributed noise. * ``'poisson'``: Poisson distributed noise. mean : float The mean of the random distribution. Required for both Gaussian and Poisson noise. The default is 0. stddev : float, optional The standard deviation of the Gaussian noise to add to the output image. Required for Gaussian noise and ignored for Poisson noise (the variance of the Poisson distribution is equal to its mean). seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Returns ------- image : 2D `~numpy.ndarray` Image containing random noise. See Also -------- apply_poisson_noise Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.datasets import make_noise_image # make Gaussian and Poisson noise images shape = (100, 100) image1 = make_noise_image(shape, distribution='gaussian', mean=0., stddev=5.) image2 = make_noise_image(shape, distribution='poisson', mean=5.) # plot the images fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(8, 4)) ax1.imshow(image1, origin='lower', interpolation='nearest') ax1.set_title(r'Gaussian noise ($\mu=0$, $\sigma=5.$)') ax2.imshow(image2, origin='lower', interpolation='nearest') ax2.set_title(r'Poisson noise ($\mu=5$)') """ if mean is None: raise ValueError('"mean" must be input') rng = np.random.default_rng(seed) if distribution == 'gaussian': if stddev is None: raise ValueError('"stddev" must be input for Gaussian noise') image = rng.normal(loc=mean, scale=stddev, size=shape) elif distribution == 'poisson': image = rng.poisson(lam=mean, size=shape) else: raise ValueError(f'Invalid distribution: {distribution}. Use either ' '"gaussian" or "poisson".') return image ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6979268 photutils-2.2.0/photutils/datasets/tests/0000755000175100001660000000000014755160634020171 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/datasets/tests/__init__.py0000644000175100001660000000000014755160622022265 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/datasets/tests/test_examples.py0000644000175100001660000000120514755160622023413 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the examples module. """ from numpy.testing import assert_allclose from photutils.datasets import make_4gaussians_image, make_100gaussians_image def test_make_4gaussians_image(): shape = (100, 200) data_sum = 177189.58 image = make_4gaussians_image() assert image.shape == shape assert_allclose(image.sum(), data_sum, rtol=1.0e-6) def test_make_100gaussians_image(): shape = (300, 500) data_sum = 826059.53 image = make_100gaussians_image() assert image.shape == shape assert_allclose(image.sum(), data_sum, rtol=1.0e-6) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/datasets/tests/test_images.py0000644000175100001660000001463714755160622023057 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the images module. """ import astropy.units as u import numpy as np import pytest from astropy.modeling.models import Moffat2D from astropy.table import QTable from numpy.testing import assert_allclose from photutils.datasets import make_model_image from photutils.psf import (CircularGaussianPSF, CircularGaussianSigmaPRF, ImagePSF) def test_make_model_image(): params = QTable() params['x_0'] = [50, 70, 90] params['y_0'] = [50, 50, 50] params['gamma'] = [1.7, 2.32, 5.8] params['alpha'] = [2.9, 5.7, 4.6] model = Moffat2D(amplitude=1) shape = (300, 500) model_shape = (11, 11) image = make_model_image(shape, model, params, model_shape=model_shape) assert image.shape == shape assert image.sum() > 1 # test variable model shape params['model_shape'] = [9, 7, 11] image = make_model_image(shape, model, params, model_shape=model_shape) assert image.shape == shape assert image.sum() > 1 # test local_bkg params['local_bkg'] = [1, 2, 3] image = make_model_image(shape, model, params, model_shape=model_shape) assert image.shape == shape assert image.sum() > 1 def test_make_model_image_units(): unit = u.Jy params = QTable() params['x_0'] = [30, 50, 70.5] params['y_0'] = [50, 50, 50.5] params['flux'] = [1, 2, 3] * unit model = 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_discretize_method(): params = QTable() params['x_0'] = [50, 70, 90] params['y_0'] = [50, 50, 50] params['gamma'] = [1.7, 2.32, 5.8] params['alpha'] = [2.9, 5.7, 4.6] model = Moffat2D(amplitude=1) shape = (300, 500) model_shape = (11, 11) for method in ('interp', 'oversample'): image = make_model_image(shape, model, params, model_shape=model_shape, discretize_method=method) assert image.shape == shape assert image.sum() > 1 def test_make_model_image_no_overlap(): params = QTable() params['x_0'] = [50] params['y_0'] = [50] params['gamma'] = [1.7] params['alpha'] = [2.9] model = Moffat2D(amplitude=1) shape = (10, 10) model_shape = (3, 3) data = make_model_image(shape, model, params, model_shape=model_shape) assert data.shape == shape assert np.sum(data) == 0 def test_make_model_image_inputs(): match = 'shape must be a 2-tuple' with pytest.raises(ValueError, match=match): make_model_image(100, Moffat2D(), QTable()) match = 'model must be a Model instance' with pytest.raises(TypeError, match=match): make_model_image((100, 100), None, QTable()) match = 'model must be a 2D model' model = Moffat2D() model.n_inputs = 1 with pytest.raises(ValueError, match=match): make_model_image((100, 100), model, QTable()) match = 'params_table must be an astropy Table' model = Moffat2D() with pytest.raises(TypeError, match=match): make_model_image((100, 100), model, None) match = 'not in model parameter names' model = Moffat2D() with pytest.raises(ValueError, match=match): make_model_image((100, 100), model, QTable(), x_name='invalid') match = 'not in params_table column names' model = Moffat2D() with pytest.raises(ValueError, match=match): make_model_image((100, 100), model, QTable(), y_name='invalid') model = Moffat2D() params = QTable() with pytest.raises(ValueError, match=match): make_model_image((100, 100), model, params) model = Moffat2D() params = QTable() params['x_0'] = [50, 70, 90] with pytest.raises(ValueError, match=match): make_model_image((100, 100), model, params) match = 'model_shape must be specified if the model does not have' params = QTable() params['x_0'] = [50] params['y_0'] = [50] params['gamma'] = [1.7] params['alpha'] = [2.9] model = Moffat2D(amplitude=1) shape = (100, 100) with pytest.raises(ValueError, match=match): make_model_image(shape, model, params) def test_make_model_image_bbox(): model1 = CircularGaussianPSF(x_0=50, y_0=50, fwhm=10) yy, xx = np.mgrid[:101, :101] model2 = ImagePSF(model1(xx, yy), x_0=50, y_0=50) params = QTable() params['x_0'] = [50, 70, 90] params['y_0'] = [50, 50, 50] shape = (100, 151) image1 = make_model_image(shape, model2, params, bbox_factor=10) image2 = make_model_image(shape, model2, params, bbox_factor=None) assert_allclose(image1, image2) image3 = make_model_image(shape, model1, params, bbox_factor=10) image4 = make_model_image(shape, model1, params, bbox_factor=None) assert_allclose(image3, image4) model1.bbox_factor = 10 image5 = make_model_image(shape, model1, params) assert np.sum(image5) > np.sum(image4) assert_allclose(image3, image4) def test_make_model_image_params_map(): 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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/datasets/tests/test_load.py0000644000175100001660000000110114755160622022507 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the load module. """ import pytest from photutils.datasets import get_path, load def test_get_path(): 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') @pytest.mark.remote_data def test_load_star_image(): hdu = load.load_star_image() assert len(hdu.header) == 106 assert hdu.data.shape == (1059, 1059) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/datasets/tests/test_model_params.py0000644000175100001660000000762514755160622024254 0ustar00runnerdocker# 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(): shape = (100, 100) n_sources = 10 flux = (100, 1000) params = make_model_params(shape, n_sources, flux=flux) assert isinstance(params, Table) assert len(params) == 10 cols = ('id', 'x_0', 'y_0', 'flux') for col in cols: assert col in params.colnames assert np.min(params[col]) >= 0 assert np.min(params['flux']) >= flux[0] assert np.max(params['flux']) <= flux[1] # test extra parameters sigma = (1, 2) alpha = (0, 1) params = make_model_params((120, 100), 5, flux=flux, sigma=sigma, alpha=alpha, min_separation=3, border_size=10, seed=0) cols = ('id', 'x_0', 'y_0', 'flux', 'sigma', 'alpha') for col in cols: assert col in params.colnames assert np.min(params[col]) >= 0 assert np.min(params['flux']) >= flux[0] assert np.max(params['flux']) <= flux[1] assert np.min(params['sigma']) >= sigma[0] assert np.max(params['sigma']) <= sigma[1] assert np.min(params['alpha']) >= alpha[0] assert np.max(params['alpha']) <= alpha[1] match = 'flux must be a 2-tuple' with pytest.raises(ValueError, match=match): make_model_params(shape, n_sources, flux=(1, 2, 3)) match = 'must be a 2-tuple' with pytest.raises(ValueError, match=match): make_model_params(shape, n_sources, flux=(1, 2), alpha=(1, 2, 3)) def test_make_model_params_nsources(): """ Test case when the number of the possible sources is less than ``n_sources``. """ match = r'Unable to produce .* coordinates within the given shape' with pytest.warns(AstropyUserWarning, match=match): shape = (200, 500) n_sources = 100 params = make_model_params(shape, n_sources, min_separation=50, amplitude=(100, 500), x_stddev=(1, 5), y_stddev=(1, 5), theta=(0, np.pi)) assert len(params) < 100 def test_make_model_params_border_size(): shape = (10, 10) n_sources = 10 flux = (100, 1000) match = 'border_size is too large for the given shape' with pytest.raises(ValueError, match=match): make_model_params(shape, n_sources, flux=flux, border_size=20) def test_make_random_models_table(): param_ranges = {'x_0': (0, 300), 'y_0': (0, 500), 'gamma': (1, 3), 'alpha': (1.5, 3)} source_table = make_random_models_table(10, param_ranges) assert len(source_table) == 10 assert 'id' in source_table.colnames cols = ('x_0', 'y_0', 'gamma', 'alpha') for col in cols: assert col in source_table.colnames assert np.min(source_table[col]) >= param_ranges[col][0] assert np.max(source_table[col]) <= param_ranges[col][1] def test_params_table_to_models(): 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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/datasets/tests/test_noise.py0000644000175100001660000000326214755160622022717 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the noise module. """ import numpy as np import pytest from numpy.testing import assert_allclose from photutils.datasets import apply_poisson_noise, make_noise_image def test_apply_poisson_noise(): shape = (100, 100) data = np.ones(shape) result = apply_poisson_noise(data) assert result.shape == shape assert_allclose(result.mean(), 1.0, atol=1.0) def test_apply_poisson_noise_negative(): """ Test if negative image values raises ValueError. """ shape = (100, 100) data = np.zeros(shape) - 1.0 match = 'data must not contain any negative values' with pytest.raises(ValueError, match=match): apply_poisson_noise(data) def test_make_noise_image(): shape = (100, 100) image = make_noise_image(shape, 'gaussian', mean=0.0, stddev=2.0) assert image.shape == shape assert_allclose(image.mean(), 0.0, atol=1.0) def test_make_noise_image_poisson(): shape = (100, 100) image = make_noise_image(shape, 'poisson', mean=1.0) assert image.shape == shape assert_allclose(image.mean(), 1.0, atol=1.0) def test_make_noise_image_nomean(): """ Test invalid inputs. """ shape = (100, 100) match = 'Invalid distribution:' with pytest.raises(ValueError, match=match): make_noise_image(shape, 'invalid', mean=0, stddev=2.0) match = '"mean" must be input' with pytest.raises(ValueError, match=match): make_noise_image(shape, 'gaussian', stddev=2.0) match = '"stddev" must be input for Gaussian noise' with pytest.raises(ValueError, match=match): make_noise_image(shape, 'gaussian', mean=2.0) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/datasets/tests/test_wcs.py0000644000175100001660000000264714755160622022404 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the wcs module. """ import pytest from numpy.testing import assert_allclose from photutils.datasets import make_gwcs, make_wcs from photutils.utils._optional_deps import HAS_GWCS def test_make_wcs(): shape = (100, 200) wcs = make_wcs(shape) assert wcs.pixel_shape == shape assert wcs.wcs.radesys == 'ICRS' wcs = make_wcs(shape, galactic=True) assert wcs.wcs.ctype[0] == 'GLON-CAR' assert wcs.wcs.ctype[1] == 'GLAT-CAR' @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') def test_make_gwcs(): shape = (100, 200) wcs = make_gwcs(shape) assert wcs.pixel_n_dim == 2 assert wcs.available_frames == ['detector', 'icrs'] assert wcs.output_frame.name == 'icrs' assert wcs.output_frame.axes_names == ('lon', 'lat') wcs = make_gwcs(shape, galactic=True) assert wcs.pixel_n_dim == 2 assert wcs.available_frames == ['detector', 'galactic'] assert wcs.output_frame.name == 'galactic' assert wcs.output_frame.axes_names == ('lon', 'lat') @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') def test_make_wcs_compare(): shape = (200, 300) wcs = make_wcs(shape) gwcs_obj = make_gwcs(shape) sc1 = wcs.pixel_to_world((50, 75), (50, 100)) sc2 = gwcs_obj.pixel_to_world((50, 75), (50, 100)) assert_allclose(sc1.ra, sc2.ra) assert_allclose(sc1.dec, sc2.dec) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/datasets/wcs.py0000644000175100001660000001156314755160622020200 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for making example WCS objects. """ import astropy.units as u import numpy as np from astropy import coordinates as coord from astropy.modeling import models from astropy.wcs import WCS __all__ = ['make_gwcs', 'make_wcs'] __doctest_requires__ = {'make_gwcs': ['gwcs']} def make_wcs(shape, galactic=False): """ Create a simple celestial `~astropy.wcs.WCS` object in either the ICRS or Galactic coordinate frame. Parameters ---------- shape : 2-tuple of int The shape of the 2D array to be used with the output `~astropy.wcs.WCS` object. galactic : bool, optional If `True`, then the output WCS will be in the Galactic coordinate frame. If `False` (default), then the output WCS will be in the ICRS coordinate frame. Returns ------- wcs : `astropy.wcs.WCS` object The world coordinate system (WCS) transformation. See Also -------- make_gwcs Notes ----- The `make_gwcs` function returns an equivalent WCS transformation to this one, but as a `gwcs.wcs.WCS` object. Examples -------- >>> from photutils.datasets import make_wcs >>> shape = (100, 100) >>> wcs = make_wcs(shape) >>> print(wcs.wcs.crpix) # doctest: +FLOAT_CMP [50. 50.] >>> print(wcs.wcs.crval) # doctest: +FLOAT_CMP [197.8925 -1.36555556] >>> skycoord = wcs.pixel_to_world(42, 57) >>> print(skycoord) # doctest: +FLOAT_CMP """ wcs = WCS(naxis=2) rho = np.pi / 3.0 scale = 0.1 / 3600.0 # 0.1 arcsec/pixel in deg/pix wcs.pixel_shape = shape wcs.wcs.crpix = [shape[1] / 2, shape[0] / 2] # 1-indexed (x, y) wcs.wcs.crval = [197.8925, -1.36555556] wcs.wcs.cunit = ['deg', 'deg'] wcs.wcs.cd = [[-scale * np.cos(rho), scale * np.sin(rho)], [scale * np.sin(rho), scale * np.cos(rho)]] if not galactic: wcs.wcs.radesys = 'ICRS' wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] else: wcs.wcs.ctype = ['GLON-CAR', 'GLAT-CAR'] return wcs def make_gwcs(shape, galactic=False): """ Create a simple celestial gWCS object in the ICRS coordinate frame. This function requires the `gwcs `_ package. Parameters ---------- shape : 2-tuple of int The shape of the 2D array to be used with the output `~gwcs.wcs.WCS` object. galactic : bool, optional If `True`, then the output WCS will be in the Galactic coordinate frame. If `False` (default), then the output WCS will be in the ICRS coordinate frame. Returns ------- wcs : `gwcs.wcs.WCS` object The generalized world coordinate system (WCS) transformation. See Also -------- make_wcs Notes ----- The `make_wcs` function returns an equivalent WCS transformation to this one, but as an `astropy.wcs.WCS` object. Examples -------- >>> from photutils.datasets import make_gwcs >>> shape = (100, 100) >>> gwcs = make_gwcs(shape) >>> print(gwcs) From Transform -------- ---------------- detector linear_transform icrs None >>> skycoord = gwcs.pixel_to_world(42, 57) >>> print(skycoord) # doctest: +FLOAT_CMP """ from gwcs import coordinate_frames as cf from gwcs import wcs as gwcs_wcs rho = np.pi / 3.0 scale = 0.1 / 3600.0 # 0.1 arcsec/pixel in deg/pix shift_by_crpix = (models.Shift((-shape[1] / 2) + 1) & models.Shift((-shape[0] / 2) + 1)) cd_matrix = np.array([[-scale * np.cos(rho), scale * np.sin(rho)], [scale * np.sin(rho), scale * np.cos(rho)]]) rotation = models.AffineTransformation2D(cd_matrix, translation=[0, 0]) rotation.inverse = models.AffineTransformation2D( np.linalg.inv(cd_matrix), translation=[0, 0]) tan = models.Pix2Sky_TAN() celestial_rotation = models.RotateNative2Celestial(197.8925, -1.36555556, 180.0) det2sky = shift_by_crpix | rotation | tan | celestial_rotation det2sky.name = 'linear_transform' detector_frame = cf.Frame2D(name='detector', axes_names=('x', 'y'), unit=(u.pix, u.pix)) if galactic: sky_frame = cf.CelestialFrame(reference_frame=coord.Galactic(), name='galactic', unit=(u.deg, u.deg)) else: sky_frame = cf.CelestialFrame(reference_frame=coord.ICRS(), name='icrs', unit=(u.deg, u.deg)) pipeline = [(detector_frame, det2sky), (sky_frame, None)] return gwcs_wcs.WCS(pipeline) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.6999266 photutils-2.2.0/photutils/detection/0000755000175100001660000000000014755160634017175 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/detection/__init__.py0000644000175100001660000000063714755160622021311 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/detection/core.py0000644000175100001660000002306214755160622020477 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module implements the base class and star finder kernel for detecting stars in an astronomical image. Each star-finding class should define a method called ``find_stars`` that finds stars in an image. """ import abc import math import warnings import numpy as np from astropy.stats import gaussian_fwhm_to_sigma from photutils.detection.peakfinder import find_peaks from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['StarFinderBase'] class StarFinderBase(metaclass=abc.ABCMeta): """ Abstract base class for star finders. """ def __call__(self, data, mask=None): return self.find_stars(data, mask=mask) @staticmethod def _find_stars(convolved_data, kernel, threshold, *, min_separation=0.0, mask=None, exclude_border=False): """ Find stars in an image. Parameters ---------- convolved_data : 2D array_like The convolved 2D array. kernel : `_StarFinderKernel` or 2D `~numpy.ndarray` The convolution kernel. ``StarFinder`` inputs the kernel as a 2D array. threshold : float The absolute image value above which to select sources. This threshold should be the threshold input to the star finder class multiplied by the kernel relerr. If ``convolved_data`` is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units. min_separation : float, optional The minimum separation for detected objects in pixels. mask : 2D bool array, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked pixels are ignored when searching for stars. exclude_border : bool, optional Set to `True` to exclude sources found within half the size of the convolution kernel from the image borders. The default is `False`, which is the mode used by IRAF's `DAOFIND `_ and `starfind `_. Returns ------- result : Nx2 `~numpy.ndarray` A Nx2 array containing the (x, y) pixel coordinates. """ # define a local footprint for the peak finder if min_separation == 0.0: # DAOStarFinder if isinstance(kernel, np.ndarray): footprint = np.ones(kernel.shape) else: footprint = kernel.mask.astype(bool) else: # define a local circular footprint for the peak finder idx = np.arange(-min_separation, min_separation + 1) xx, yy = np.meshgrid(idx, idx) footprint = np.array((xx**2 + yy**2) <= min_separation**2, dtype=int) # 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.yradius xborder = kernel.xradius 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, footprint=footprint, mask=mask, border_width=border_width) if tbl is None: return None return np.transpose((tbl['x_peak'], tbl['y_peak'])) @abc.abstractmethod def find_stars(self, data, mask=None): """ Find stars in an astronomical image. Parameters ---------- data : 2D array_like The 2D image array. mask : 2D bool array, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked pixels are ignored when searching for stars. Returns ------- table : `~astropy.table.Table` or `None` A table of found stars. If no stars are found then `None` is returned. """ raise NotImplementedError('Needs to be implemented in a subclass.') class _StarFinderKernel: """ Container class for a 2D Gaussian density enhancement kernel. The kernel has negative wings and sums to zero. It is used by both `DAOStarFinder` and `IRAFStarFinder`. Parameters ---------- fwhm : float The full-width half-maximum (FWHM) of the major axis of the Gaussian kernel in units of pixels. ratio : float, optional The ratio of the minor and major axis standard deviations of the Gaussian kernel. ``ratio`` must be strictly positive and less than or equal to 1.0. The default is 1.0 (i.e., a circular Gaussian kernel). theta : float, optional The position angle (in degrees) of the major axis of the Gaussian kernel, measured counter-clockwise from the positive x axis. sigma_radius : float, optional The truncation radius of the Gaussian kernel in units of sigma (standard deviation) [``1 sigma = FWHM / 2.0*sqrt(2.0*log(2.0))``]. The default is 1.5. normalize_zerosum : bool, optional Whether to normalize the Gaussian kernel to have zero sum, The default is `True`, which generates a density-enhancement kernel. Notes ----- The class attributes include the dimensions of the elliptical kernel and the coefficients of a 2D elliptical Gaussian function expressed as: ``f(x,y) = A * exp(-g(x,y))`` where ``g(x,y) = a*(x-x0)**2 + 2*b*(x-x0)*(y-y0) + c*(y-y0)**2`` References ---------- .. [1] https://en.wikipedia.org/wiki/Gaussian_function """ def __init__(self, fwhm, *, ratio=1.0, theta=0.0, sigma_radius=1.5, normalize_zerosum=True): if fwhm < 0: raise ValueError('fwhm must be positive.') if ratio <= 0 or ratio > 1: raise ValueError('ratio must be positive and less or equal ' 'than 1.') if sigma_radius <= 0: raise ValueError('sigma_radius must be positive.') self.fwhm = fwhm self.ratio = ratio self.theta = theta self.sigma_radius = sigma_radius self.xsigma = self.fwhm * gaussian_fwhm_to_sigma self.ysigma = self.xsigma * self.ratio theta_radians = np.deg2rad(self.theta) cost = np.cos(theta_radians) sint = np.sin(theta_radians) xsigma2 = self.xsigma**2 ysigma2 = self.ysigma**2 self.a = (cost**2 / (2.0 * xsigma2)) + (sint**2 / (2.0 * ysigma2)) # CCW self.b = 0.5 * cost * sint * ((1.0 / xsigma2) - (1.0 / ysigma2)) self.c = (sint**2 / (2.0 * xsigma2)) + (cost**2 / (2.0 * ysigma2)) # find the extent of an ellipse with radius = sigma_radius*sigma; # solve for the horizontal and vertical tangents of an ellipse # defined by g(x,y) = f self.f = self.sigma_radius**2 / 2.0 denom = (self.a * self.c) - self.b**2 # nx and ny are always odd # minimum kernel size is 5x5 self.nx = 2 * int(max(2, math.sqrt(self.c * self.f / denom))) + 1 self.ny = 2 * int(max(2, math.sqrt(self.a * self.f / denom))) + 1 self.xc = self.xradius = self.nx // 2 self.yc = self.yradius = self.ny // 2 # define the kernel on a 2D grid yy, xx = np.mgrid[0:self.ny, 0:self.nx] self.circular_radius = np.sqrt((xx - self.xc)**2 + (yy - self.yc)**2) self.elliptical_radius = (self.a * (xx - self.xc)**2 + 2.0 * self.b * (xx - self.xc) * (yy - self.yc) + self.c * (yy - self.yc)**2) self.mask = np.where( (self.elliptical_radius <= self.f) | (self.circular_radius <= 2.0), 1, 0).astype(int) self.npixels = self.mask.sum() # NOTE: the central (peak) pixel of gaussian_kernel has a value of 1.0 self.gaussian_kernel_unmasked = np.exp(-self.elliptical_radius) self.gaussian_kernel = self.gaussian_kernel_unmasked * self.mask # The denom represents (variance * npixels) denom = ((self.gaussian_kernel**2).sum() - (self.gaussian_kernel.sum()**2 / self.npixels)) self.relerr = 1.0 / np.sqrt(denom) # normalize the kernel to zero sum if normalize_zerosum: self.data = ((self.gaussian_kernel - (self.gaussian_kernel.sum() / self.npixels)) / denom) * self.mask else: # pragma: no cover self.data = self.gaussian_kernel self.shape = self.data.shape def _validate_brightest(brightest): """ Validate the ``brightest`` parameter. It must be >0 and an integer. """ if brightest is not None: if brightest <= 0: raise ValueError('brightest must be >= 0') bright_int = int(brightest) if bright_int != brightest: raise ValueError('brightest must be an integer') brightest = bright_int return brightest ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/detection/daofinder.py0000644000175100001660000007211614755160622021506 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module implements the DAOStarFinder class. """ import inspect import warnings import astropy.units as u import numpy as np from astropy.nddata import extract_array from astropy.table import QTable from astropy.utils import lazyproperty from photutils.detection.core import (StarFinderBase, _StarFinderKernel, _validate_brightest) from photutils.utils._convolution import _filter_data from photutils.utils._misc import _get_meta from photutils.utils._quantity_helpers import isscalar, process_quantities from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['DAOStarFinder'] class DAOStarFinder(StarFinderBase): """ Detect stars in an image using the DAOFIND (`Stetson 1987 `_) algorithm. DAOFIND searches images for local density maxima that have a peak amplitude greater than ``threshold`` (approximately; ``threshold`` is applied to a convolved image) and have a size and shape similar to the defined 2D Gaussian kernel. The Gaussian kernel is defined by the ``fwhm``, ``ratio``, ``theta``, and ``sigma_radius`` input parameters. ``DAOStarFinder`` finds the object centroid by fitting the marginal x and y 1D distributions of the Gaussian kernel to the marginal x and y distributions of the input (unconvolved) ``data`` image. ``DAOStarFinder`` calculates the object roundness using two methods. The ``roundlo`` and ``roundhi`` bounds are applied to both measures of roundness. The first method (``roundness1``; called ``SROUND`` in DAOFIND) is based on the source symmetry and is the ratio of a measure of the object's bilateral (2-fold) to four-fold symmetry. The second roundness statistic (``roundness2``; called ``GROUND`` in DAOFIND) measures the ratio of the difference in the height of the best fitting Gaussian function in x minus the best fitting Gaussian function in y, divided by the average of the best fitting Gaussian functions in x and y. A circular source will have a zero roundness. A source extended in x or y will have a negative or positive roundness, respectively. The sharpness statistic measures the ratio of the difference between the height of the central pixel and the mean of the surrounding non-bad pixels in the convolved image, to the height of the best fitting Gaussian function at that point. Parameters ---------- threshold : float The absolute image value above which to select sources. If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units. fwhm : float The full-width half-maximum (FWHM) of the major axis of the Gaussian kernel in units of pixels. ratio : float, optional The ratio of the minor to major axis standard deviations of the Gaussian kernel. ``ratio`` must be strictly positive and less than or equal to 1.0. The default is 1.0 (i.e., a circular Gaussian kernel). theta : float, optional The position angle (in degrees) of the major axis of the Gaussian kernel measured counter-clockwise from the positive x axis. sigma_radius : float, optional The truncation radius of the Gaussian kernel in units of sigma (standard deviation) [``1 sigma = FWHM / (2.0*sqrt(2.0*log(2.0)))``]. sharplo : float, optional The lower bound on sharpness for object detection. Objects with sharpness less than ``sharplo`` will be rejected. sharphi : float, optional The upper bound on sharpness for object detection. Objects with sharpness greater than ``sharphi`` will be rejected. roundlo : float, optional The lower bound on roundness for object detection. Objects with roundness less than ``roundlo`` will be rejected. roundhi : float, optional The upper bound on roundness for object detection. Objects with roundness greater than ``roundhi`` will be rejected. exclude_border : bool, optional Set to `True` to exclude sources found within half the size of the convolution kernel from the image borders. The default is `False`, which is the mode used by DAOFIND. brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``brightest`` is set to `None`, all objects will be selected. peakmax : float, None, optional The maximum allowed peak pixel value in an object. Objects with peak pixel values greater than ``peakmax`` 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 ``peakmax`` must have the same units. If ``peakmax`` is set to `None`, then no peak pixel value filtering will be performed. xycoords : `None` or Nx2 `~numpy.ndarray`, optional The (x, y) pixel coordinates of the approximate centroid positions of identified sources. If ``xycoords`` are input, the algorithm will skip the source-finding step. min_separation : float, optional The minimum separation (in pixels) for detected objects. Note that large values may result in long run times. See Also -------- IRAFStarFinder Notes ----- If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``threshold`` and ``peakmax`` must have the same units as the image. For the convolution step, this routine sets pixels beyond the image borders to 0.0. The equivalent parameters in DAOFIND are ``boundary='constant'`` and ``constant=0.0``. The main differences between `~photutils.detection.DAOStarFinder` and `~photutils.detection.IRAFStarFinder` are: * `~photutils.detection.IRAFStarFinder` always uses a 2D circular Gaussian kernel, while `~photutils.detection.DAOStarFinder` can use an elliptical Gaussian kernel. * `~photutils.detection.IRAFStarFinder` calculates the objects' centroid, roundness, and sharpness using image moments. References ---------- .. [1] Stetson, P. 1987; PASP 99, 191 (https://ui.adsabs.harvard.edu/abs/1987PASP...99..191S/abstract) """ def __init__(self, threshold, fwhm, ratio=1.0, theta=0.0, sigma_radius=1.5, sharplo=0.2, sharphi=1.0, roundlo=-1.0, roundhi=1.0, exclude_border=False, brightest=None, peakmax=None, xycoords=None, min_separation=0.0): # here we validate the units, but do not strip them inputs = (threshold, peakmax) names = ('threshold', 'peakmax') _ = process_quantities(inputs, names) if not isscalar(threshold): raise ValueError('threshold must be a scalar value.') if not np.isscalar(fwhm): raise TypeError('fwhm must be a scalar value.') self.threshold = threshold self.fwhm = fwhm self.ratio = ratio self.theta = theta self.sigma_radius = sigma_radius self.sharplo = sharplo self.sharphi = sharphi self.roundlo = roundlo self.roundhi = roundhi self.exclude_border = exclude_border self.brightest = _validate_brightest(brightest) self.peakmax = peakmax if min_separation < 0: raise ValueError('min_separation must be >= 0') self.min_separation = min_separation if xycoords is not None: xycoords = np.asarray(xycoords) if xycoords.ndim != 2 or xycoords.shape[1] != 2: raise ValueError('xycoords must be shaped as a Nx2 array') self.xycoords = xycoords self.kernel = _StarFinderKernel(self.fwhm, ratio=self.ratio, theta=self.theta, sigma_radius=self.sigma_radius) self.threshold_eff = self.threshold * self.kernel.relerr def _get_raw_catalog(self, data, *, mask=None): convolved_data = _filter_data(data, self.kernel.data, mode='constant', fill_value=0.0, check_normalization=False) if self.xycoords is None: xypos = self._find_stars(convolved_data, self.kernel, self.threshold_eff, mask=mask, min_separation=self.min_separation, exclude_border=self.exclude_border) else: xypos = self.xycoords if xypos is None: warnings.warn('No sources were found.', NoDetectionsWarning) return None return _DAOStarFinderCatalog(data, convolved_data, xypos, self.threshold, self.kernel, sharplo=self.sharplo, sharphi=self.sharphi, roundlo=self.roundlo, roundhi=self.roundhi, brightest=self.brightest, peakmax=self.peakmax) def find_stars(self, data, mask=None): """ Find stars in an astronomical image. Parameters ---------- data : 2D array_like The 2D image array. The image should be background-subtracted. mask : 2D bool array, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked pixels are ignored when searching for stars. Returns ------- table : `~astropy.table.QTable` or `None` A table of found stars. `None` is returned if no stars are found. The table contains the following parameters: * ``id``: unique object identification number. * ``xcentroid, ycentroid``: object centroid. * ``sharpness``: object sharpness. * ``roundness1``: object roundness based on symmetry. * ``roundness2``: object roundness based on marginal Gaussian fits. * ``npix``: the total number of pixels in the Gaussian kernel array. * ``peak``: the peak pixel value of the object. * ``flux``: the object instrumental flux calculated as the sum of data values within the kernel footprint. * ``mag``: the object instrumental magnitude calculated as ``-2.5 * log10(flux)``. * ``daofind_mag``: the "mag" parameter returned by the DAOFIND algorithm. It is a measure of the intensity ratio of the amplitude of the best fitting Gaussian function at the object position to the detection threshold. This parameter is reported only for comparison to the IRAF DAOFIND output. It should not be interpreted as a magnitude derived from an integrated flux. """ # here we validate the units, but do not strip them inputs = (data, self.threshold, self.peakmax) names = ('data', 'threshold', 'peakmax') _ = process_quantities(inputs, names) cat = self._get_raw_catalog(data, mask=mask) if cat is None: return None # apply all selection filters cat = cat.apply_all_filters() if cat is None: return None # create the output table return cat.to_table() class _DAOStarFinderCatalog: """ Class to create a catalog of the properties of each detected star, as defined by DAOFIND. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image. The image should be background-subtracted. convolved_data : 2D `~numpy.ndarray` The convolved 2D image. If ``data`` is a `~astropy.units.Quantity` array, then ``convolved_data`` must have the same units. xypos : Nx2 `~numpy.ndarray` A Nx2 array of (x, y) pixel coordinates denoting the central positions of the stars. threshold : float The absolute image value above which sources were selected. If ``data`` is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units. kernel : `_StarFinderKernel` The convolution kernel. This kernel must match the kernel used to create the ``convolved_data``. sharplo : float, optional The lower bound on sharpness for object detection. Objects with sharpness less than ``sharplo`` will be rejected. sharphi : float, optional The upper bound on sharpness for object detection. Objects with sharpness greater than ``sharphi`` will be rejected. roundlo : float, optional The lower bound on roundness for object detection. Objects with roundness less than ``roundlo`` will be rejected. roundhi : float, optional The upper bound on roundness for object detection. Objects with roundness greater than ``roundhi`` will be rejected. brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``brightest`` is set to `None`, all objects will be selected. peakmax : float, None, optional The maximum allowed peak pixel value in an object. Objects with peak pixel values greater than ``peakmax`` 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 ``peakmax`` must have the same units. If ``peakmax`` is set to `None`, then no peak pixel value filtering will be performed. """ def __init__(self, data, convolved_data, xypos, threshold, kernel, *, sharplo=0.2, sharphi=1.0, roundlo=-1.0, roundhi=1.0, brightest=None, peakmax=None): # here we validate the units, but do not strip them inputs = (data, convolved_data, threshold, peakmax) names = ('data', 'convolved_data', 'threshold', 'peakmax') _ = process_quantities(inputs, names) self.data = data unit = data.unit if isinstance(data, u.Quantity) else None self.unit = unit self.convolved_data = convolved_data self.xypos = np.atleast_2d(xypos) self.kernel = kernel self.threshold = threshold self.sharplo = sharplo self.sharphi = sharphi self.roundlo = roundlo self.roundhi = roundhi self.brightest = brightest self.peakmax = peakmax self.id = np.arange(len(self)) + 1 self.threshold_eff = threshold * kernel.relerr self.cutout_shape = kernel.shape self.cutout_center = tuple((size - 1) // 2 for size in kernel.shape) self.default_columns = ('id', 'xcentroid', 'ycentroid', 'sharpness', 'roundness1', 'roundness2', 'npix', 'peak', 'flux', 'mag', 'daofind_mag') def __len__(self): return len(self.xypos) def __getitem__(self, index): # NOTE: we allow indexing/slicing of scalar (self.isscalar = True) # instances in order to perform catalog filtering even for # a single source newcls = object.__new__(self.__class__) # copy these attributes to the new instance init_attr = ('data', 'unit', 'convolved_data', 'kernel', 'threshold', 'sharplo', 'sharphi', 'roundlo', 'roundhi', 'brightest', 'peakmax', 'threshold_eff', 'cutout_shape', 'cutout_center', 'default_columns') for attr in init_attr: setattr(newcls, attr, getattr(self, attr)) # xypos determines ordering and isscalar # NOTE: always keep as a 2D array, even for a single source attr = 'xypos' value = getattr(self, attr)[index] setattr(newcls, attr, np.atleast_2d(value)) # slice the other attributes keys = set(self.__dict__.keys()) & set(self._lazyproperties) keys.add('id') for key in keys: value = self.__dict__[key] # do not insert lazy attributes that are always scalar (e.g., # isscalar), i.e., not an array/list for each source if np.isscalar(value): continue # value is always at least a 1D array, even for a single source value = np.atleast_1d(value[index]) newcls.__dict__[key] = value return newcls @lazyproperty def isscalar(self): """ Whether the instance is scalar (e.g., a single source). """ return self.xypos.shape == (1, 2) @property def _lazyproperties(self): """ Return all lazyproperties (even in superclasses). """ def islazyproperty(obj): return isinstance(obj, lazyproperty) return [i[0] for i in inspect.getmembers(self.__class__, predicate=islazyproperty)] def reset_ids(self): """ Reset the ID column to be consecutive integers. """ self.id = np.arange(len(self)) + 1 def make_cutouts(self, data): cutouts = [] for xpos, ypos in self.xypos: cutouts.append(extract_array(data, self.cutout_shape, (ypos, xpos), fill_value=0.0)) value = np.array(cutouts) if self.unit is not None: value <<= self.unit return value @lazyproperty def cutout_data(self): return self.make_cutouts(self.data) @lazyproperty def cutout_convdata(self): return self.make_cutouts(self.convolved_data) @lazyproperty def data_peak(self): return self.cutout_data[:, self.cutout_center[0], self.cutout_center[1]] @lazyproperty def convdata_peak(self): return self.cutout_convdata[:, self.cutout_center[0], self.cutout_center[1]] @lazyproperty def roundness1(self): # set the central (peak) pixel to zero for the sum4 calculation cutout_conv = self.cutout_convdata.copy() cutout_conv[:, self.cutout_center[0], self.cutout_center[1]] = 0.0 # calculate the four roundness quadrants. # the cutout size always matches the kernel size, which has odd # dimensions. # quad1 = bottom right # quad2 = bottom left # quad3 = top left # quad4 = top right # 3 3 4 4 4 # 3 3 4 4 4 # 3 3 x 1 1 # 2 2 2 1 1 # 2 2 2 1 1 quad1 = cutout_conv[:, 0:self.cutout_center[0] + 1, self.cutout_center[1] + 1:] quad2 = cutout_conv[:, 0:self.cutout_center[0], 0:self.cutout_center[1] + 1] quad3 = cutout_conv[:, self.cutout_center[0]:, 0:self.cutout_center[1]] quad4 = cutout_conv[:, self.cutout_center[0] + 1:, self.cutout_center[1]:] axis = (1, 2) sum2 = (-quad1.sum(axis=axis) + quad2.sum(axis=axis) - quad3.sum(axis=axis) + quad4.sum(axis=axis)) sum4 = np.abs(cutout_conv).sum(axis=axis) # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return 2.0 * sum2 / sum4 @lazyproperty def sharpness(self): # mean value of the unconvolved data (excluding the peak) cutout_data_masked = self.cutout_data * self.kernel.mask data_mean = ((np.sum(cutout_data_masked, axis=(1, 2)) - self.data_peak) / (self.kernel.npixels - 1)) with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return (self.data_peak - data_mean) / self.convdata_peak def daofind_marginal_fit(self, axis=0): """ Fit 1D Gaussians, defined from the marginal x/y kernel distributions, to the marginal x/y distributions of the original (unconvolved) image. These fits are used calculate the star centroid and roundness2 ("GROUND") properties. Parameters ---------- axis : {0, 1}, optional The axis for which the marginal fit is performed: * 0: for the x axis * 1: for the y axis Returns ------- dx : float The fractional shift in x or y (depending on ``axis`` value) of the image centroid relative to the maximum pixel. hx : float The height of the best-fitting Gaussian to the marginal x or y (depending on ``axis`` value) distribution of the unconvolved source data. """ # define triangular weighting functions along each axis, peaked # in the middle and equal to one at the edge ycen, xcen = self.cutout_center xx = xcen - np.abs(np.arange(self.cutout_shape[1]) - xcen) + 1 yy = ycen - np.abs(np.arange(self.cutout_shape[0]) - ycen) + 1 xwt, ywt = np.meshgrid(xx, yy) if axis == 0: # marginal distributions along x axis wt = xwt[0] # 1D wts = ywt # 2D size = self.cutout_shape[1] center = xcen sigma = self.kernel.xsigma dxx = center - np.arange(size) elif axis == 1: # marginal distributions along y axis wt = np.transpose(ywt)[0] # 1D wts = xwt # 2D size = self.cutout_shape[0] center = ycen sigma = self.kernel.ysigma dxx = np.arange(size) - center # compute marginal sums for given axis wt_sum = np.sum(wt) dx = center - np.arange(size) # weighted marginal sums kern_sum_1d = np.sum(self.kernel.gaussian_kernel_unmasked * wts, axis=axis) kern_sum = np.sum(kern_sum_1d * wt) kern2_sum = np.sum(kern_sum_1d**2 * wt) dkern_dx = kern_sum_1d * dx dkern_dx_sum = np.sum(dkern_dx * wt) dkern_dx2_sum = np.sum(dkern_dx**2 * wt) kern_dkern_dx_sum = np.sum(kern_sum_1d * dkern_dx * wt) cutout_data = self.cutout_data if isinstance(cutout_data, u.Quantity): cutout_data = cutout_data.value data_sum_1d = np.sum(cutout_data * wts, axis=axis + 1) data_sum = np.sum(data_sum_1d * wt, axis=1) data_kern_sum = np.sum(data_sum_1d * kern_sum_1d * wt, axis=1) data_dkern_dx_sum = np.sum(data_sum_1d * dkern_dx * wt, axis=1) data_dx_sum = np.sum(data_sum_1d * dxx * wt, axis=1) # perform linear least-squares fit (where data = 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)) @lazyproperty def dx_hx(self): return self.daofind_marginal_fit(axis=0) @lazyproperty def dy_hy(self): return self.daofind_marginal_fit(axis=1) @lazyproperty def dx(self): return np.transpose(self.dx_hx)[0] @lazyproperty def dy(self): return np.transpose(self.dy_hy)[0] @lazyproperty def hx(self): return np.transpose(self.dx_hx)[1] @lazyproperty def hy(self): return np.transpose(self.dy_hy)[1] @lazyproperty def xcentroid(self): return np.transpose(self.xypos)[0] + self.dx @lazyproperty def ycentroid(self): return np.transpose(self.xypos)[1] + self.dy @lazyproperty def roundness2(self): """ The star roundness. This roundness parameter represents the ratio of the difference in the height of the best fitting Gaussian function in x minus the best fitting Gaussian function in y, divided by the average of the best fitting Gaussian functions in x and y. A circular source will have a zero roundness. A source extended in x or y will have a negative or positive roundness, respectively. """ return 2.0 * (self.hx - self.hy) / (self.hx + self.hy) @lazyproperty def peak(self): return self.data_peak @lazyproperty def flux(self): fluxes = [np.sum(arr) for arr in self.cutout_data] if self.unit is not None: fluxes = u.Quantity(fluxes) else: fluxes = np.array(fluxes) return fluxes @lazyproperty def mag(self): # ignore RunTimeWarning if flux is <= 0 with warnings.catch_warnings(): warnings.simplefilter('ignore', category=RuntimeWarning) flux = self.flux if isinstance(flux, u.Quantity): flux = flux.value return -2.5 * np.log10(flux) @lazyproperty def daofind_mag(self): """ The "mag" parameter returned by the original DAOFIND algorithm. It is a measure of the intensity ratio of the amplitude of the best fitting Gaussian function at the object position to the detection threshold. """ # ignore RunTimeWarning if flux is <= 0 with warnings.catch_warnings(): warnings.simplefilter('ignore', category=RuntimeWarning) return -2.5 * np.log10(self.convdata_peak / self.threshold_eff) @lazyproperty def npix(self): return np.full(len(self), fill_value=self.kernel.data.size) def apply_filters(self): """ Filter the catalog. """ # remove all non-finite values - consider these non-detections attrs = ('xcentroid', 'ycentroid', 'hx', 'hy', 'sharpness', 'roundness1', 'roundness2', 'peak', 'flux') mask = np.ones(len(self), dtype=bool) for attr in attrs: # if threshold_eff == 0, flux will be np.inf, but # coordinates can still be used if self.threshold_eff == 0 and attr == 'flux': continue mask &= np.isfinite(getattr(self, attr)) newcat = self[mask] if len(newcat) == 0: warnings.warn('No sources were found.', NoDetectionsWarning) return None # keep sources that are within the sharpness, roundness, and # peakmax (inclusive) bounds mask = ((newcat.sharpness >= newcat.sharplo) & (newcat.sharpness <= newcat.sharphi) & (newcat.roundness1 >= newcat.roundlo) & (newcat.roundness1 <= newcat.roundhi) & (newcat.roundness2 >= newcat.roundlo) & (newcat.roundness2 <= newcat.roundhi)) if newcat.peakmax is not None: mask &= (newcat.peak <= newcat.peakmax) newcat = newcat[mask] if len(newcat) == 0: warnings.warn('Sources were found, but none pass the sharpness, ' 'roundness, or peakmax criteria', NoDetectionsWarning) return None return newcat def select_brightest(self): """ Sort the catalog by the brightest fluxes and select the top brightest sources. """ newcat = self if self.brightest is not None: idx = np.argsort(self.flux)[::-1][:self.brightest] newcat = self[idx] return newcat def apply_all_filters(self): """ Apply all filters, select the brightest, and reset the source IDs. """ cat = self.apply_filters() if cat is None: return None cat = cat.select_brightest() cat.reset_ids() return cat def to_table(self, columns=None): table = QTable() table.meta.update(_get_meta()) # keep table.meta type if columns is None: columns = self.default_columns for column in columns: table[column] = getattr(self, column) return table ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/detection/irafstarfinder.py0000644000175100001660000005475514755160622022567 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module implements the IRAFStarFinder class. """ import inspect import warnings import astropy.units as u import numpy as np from astropy.nddata import extract_array from astropy.table import QTable from astropy.utils import lazyproperty from photutils.detection.core import (StarFinderBase, _StarFinderKernel, _validate_brightest) from photutils.utils._convolution import _filter_data from photutils.utils._misc import _get_meta from photutils.utils._moments import _moments, _moments_central from photutils.utils._quantity_helpers import isscalar, process_quantities from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['IRAFStarFinder'] class IRAFStarFinder(StarFinderBase): """ Detect stars in an image using IRAF's "starfind" algorithm. `IRAFStarFinder` searches images for local density maxima that have a peak amplitude greater than ``threshold`` above the local background and have a PSF full-width at half-maximum similar to the input ``fwhm``. The objects' centroid, roundness (ellipticity), and sharpness are calculated using image moments. Parameters ---------- threshold : float The absolute image value above which to select sources. If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units. fwhm : float The full-width half-maximum (FWHM) of the 2D circular Gaussian kernel in units of pixels. sigma_radius : float, optional The truncation radius of the Gaussian kernel in units of sigma (standard deviation) [``1 sigma = FWHM / 2.0*sqrt(2.0*log(2.0))``]. minsep_fwhm : float, optional The separation (in units of ``fwhm``) for detected objects. The minimum separation is calculated as ``int((fwhm * minsep_fwhm) + 0.5)`` and is clipped to a minimum value of 2. Note that large values may result in long run times. sharplo : float, optional The lower bound on sharpness for object detection. Objects with sharpness less than ``sharplo`` will be rejected. sharphi : float, optional The upper bound on sharpness for object detection. Objects with sharpness greater than ``sharphi`` will be rejected. roundlo : float, optional The lower bound on roundness for object detection. Objects with roundness less than ``roundlo`` will be rejected. roundhi : float, optional The upper bound on roundness for object detection. Objects with roundness greater than ``roundhi`` will be rejected. exclude_border : bool, optional Set to `True` to exclude sources found within half the size of the convolution kernel from the image borders. The default is `False`, which is the mode used by starfind. brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``brightest`` is set to `None`, all objects will be selected. peakmax : float, None, optional The maximum allowed peak pixel value in an object. Objects with peak pixel values greater than ``peakmax`` 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 ``peakmax`` must have the same units. If ``peakmax`` is set to `None`, then no peak pixel value filtering will be performed. xycoords : `None` or Nx2 `~numpy.ndarray`, optional The (x, y) pixel coordinates of the approximate centroid positions of identified sources. If ``xycoords`` are input, the algorithm will skip the source-finding step. min_separation : `None` or float, optional The minimum separation (in pixels) for detected objects. If `None` then ``minsep_fwhm`` will be used, otherwise this keyword overrides ``minsep_fwhm``. Note that large values may result in long run times. See Also -------- DAOStarFinder Notes ----- If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``threshold`` and ``peakmax`` 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))`` * ``minsep_fwhm = 0.5 * sepmin`` The main differences between `~photutils.detection.DAOStarFinder` and `~photutils.detection.IRAFStarFinder` are: * `~photutils.detection.IRAFStarFinder` always uses a 2D circular Gaussian kernel, while `~photutils.detection.DAOStarFinder` can use an elliptical Gaussian kernel. * `IRAFStarFinder` internally calculates a "sky" background level based on unmasked pixels within the kernel footprint. * `~photutils.detection.IRAFStarFinder` calculates the objects' centroid, roundness, and sharpness using image moments. """ def __init__(self, threshold, fwhm, sigma_radius=1.5, minsep_fwhm=2.5, sharplo=0.5, sharphi=2.0, roundlo=0.0, roundhi=0.2, exclude_border=False, brightest=None, peakmax=None, xycoords=None, min_separation=None): # here we validate the units, but do not strip them inputs = (threshold, peakmax) names = ('threshold', 'peakmax') _ = process_quantities(inputs, names) if not isscalar(threshold): raise TypeError('threshold must be a scalar value.') if not np.isscalar(fwhm): raise TypeError('fwhm must be a scalar value.') self.threshold = threshold self.fwhm = fwhm self.sigma_radius = sigma_radius self.minsep_fwhm = minsep_fwhm self.sharplo = sharplo self.sharphi = sharphi self.roundlo = roundlo self.roundhi = roundhi self.exclude_border = exclude_border self.brightest = _validate_brightest(brightest) self.peakmax = peakmax if xycoords is not None: xycoords = np.asarray(xycoords) if xycoords.ndim != 2 or xycoords.shape[1] != 2: raise ValueError('xycoords must be shaped as a Nx2 array') self.xycoords = xycoords self.kernel = _StarFinderKernel(self.fwhm, ratio=1.0, theta=0.0, sigma_radius=self.sigma_radius) if min_separation is not None: if min_separation < 0: raise ValueError('min_separation must be >= 0') self.min_separation = min_separation else: self.min_separation = max(2, int((self.fwhm * self.minsep_fwhm) + 0.5)) def _get_raw_catalog(self, data, *, mask=None): convolved_data = _filter_data(data, self.kernel.data, mode='constant', fill_value=0.0, check_normalization=False) if self.xycoords is None: xypos = self._find_stars(convolved_data, self.kernel, self.threshold, min_separation=self.min_separation, mask=mask, exclude_border=self.exclude_border) else: xypos = self.xycoords if xypos is None: warnings.warn('No sources were found.', NoDetectionsWarning) return None return _IRAFStarFinderCatalog(data, convolved_data, xypos, self.kernel, sharplo=self.sharplo, sharphi=self.sharphi, roundlo=self.roundlo, roundhi=self.roundhi, brightest=self.brightest, peakmax=self.peakmax) def find_stars(self, data, mask=None): """ Find stars in an astronomical image. Parameters ---------- data : 2D array_like The 2D image array. The image should be background-subtracted. mask : 2D bool array, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked pixels are ignored when searching for stars. Returns ------- table : `~astropy.table.QTable` or `None` A table of found stars. `None` is returned if no stars are found. The table contains the following parameters: * ``id``: unique object identification number. * ``xcentroid, ycentroid``: object centroid. * ``fwhm``: object FWHM. * ``sharpness``: object sharpness. * ``roundness``: object roundness. * ``pa``: object position angle (degrees counter clockwise from the positive x axis). * ``npix``: the total number of (positive) unmasked pixels. * ``peak``: the peak, sky-subtracted, pixel value of the object. * ``flux``: the object instrumental flux calculated as the sum of sky-subtracted data values within the kernel footprint. * ``mag``: the object instrumental magnitude calculated as ``-2.5 * log10(flux)``. """ inputs = (data, self.threshold, self.peakmax) names = ('data', 'threshold', 'peakmax') _ = process_quantities(inputs, names) cat = self._get_raw_catalog(data, mask=mask) if cat is None: return None # apply all selection filters cat = cat.apply_all_filters() if cat is None: return None # create the output table return cat.to_table() class _IRAFStarFinderCatalog: """ Class to create a catalog of the properties of each detected star, as defined by IRAF's ``starfind`` task. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image. The image should be background-subtracted. convolved_data : 2D `~numpy.ndarray` The convolved 2D image. If ``data`` is a `~astropy.units.Quantity` array, then ``convolved_data`` must have the same units. xypos : Nx2 `~numpy.ndarray` A Nx2 array of (x, y) pixel coordinates denoting the central positions of the stars. kernel : `_StarFinderKernel` The convolution kernel. This kernel must match the kernel used to create the ``convolved_data``. sharplo : float, optional The lower bound on sharpness for object detection. Objects with sharpness less than ``sharplo`` will be rejected. sharphi : float, optional The upper bound on sharpness for object detection. Objects with sharpness greater than ``sharphi`` will be rejected. roundlo : float, optional The lower bound on roundness for object detection. Objects with roundness less than ``roundlo`` will be rejected. roundhi : float, optional The upper bound on roundness for object detection. Objects with roundness greater than ``roundhi`` will be rejected. brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``brightest`` is set to `None`, all objects will be selected. peakmax : float, None, optional The maximum allowed peak pixel value in an object. Objects with peak pixel values greater than ``peakmax`` 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 ``peakmax`` must have the same units. If ``peakmax`` is set to `None`, then no peak pixel value filtering will be performed. """ def __init__(self, data, convolved_data, xypos, kernel, *, sharplo=0.2, sharphi=1.0, roundlo=-1.0, roundhi=1.0, brightest=None, peakmax=None): # here we validate the units, but do not strip them inputs = (data, convolved_data, peakmax) names = ('data', 'convolved_data', 'peakmax') _ = process_quantities(inputs, names) self.data = data unit = data.unit if isinstance(data, u.Quantity) else None self.unit = unit self.convolved_data = convolved_data self.xypos = xypos self.kernel = kernel self.sharplo = sharplo self.sharphi = sharphi self.roundlo = roundlo self.roundhi = roundhi self.brightest = brightest self.peakmax = peakmax self.id = np.arange(len(self)) + 1 self.cutout_shape = kernel.shape self.default_columns = ('id', 'xcentroid', 'ycentroid', 'fwhm', 'sharpness', 'roundness', 'pa', 'npix', 'peak', 'flux', 'mag') def __len__(self): return len(self.xypos) def __getitem__(self, index): # NOTE: we allow indexing/slicing of scalar (self.isscalar = True) # instances in order to perform catalog filtering even for # a single source newcls = object.__new__(self.__class__) # copy these attributes to the new instance init_attr = ('data', 'unit', 'convolved_data', 'kernel', 'sharplo', 'sharphi', 'roundlo', 'roundhi', 'brightest', 'peakmax', 'cutout_shape', 'default_columns') for attr in init_attr: setattr(newcls, attr, getattr(self, attr)) # xypos determines ordering and isscalar # NOTE: always keep as a 2D array, even for a single source attr = 'xypos' value = getattr(self, attr)[index] setattr(newcls, attr, np.atleast_2d(value)) # index/slice the remaining attributes keys = set(self.__dict__.keys()) & set(self._lazyproperties) keys.add('id') for key in keys: value = self.__dict__[key] # do not insert lazy attributes that are always scalar (e.g., # isscalar), i.e., not an array/list for each source if np.isscalar(value): continue # value is always at least a 1D array, even for a single source value = np.atleast_1d(value[index]) newcls.__dict__[key] = value return newcls @lazyproperty def isscalar(self): """ Whether the instance is scalar (e.g., a single source). """ return self.xypos.shape == (1, 2) @property def _lazyproperties(self): """ Return all lazyproperties (even in superclasses). """ def islazyproperty(obj): return isinstance(obj, lazyproperty) return [i[0] for i in inspect.getmembers(self.__class__, predicate=islazyproperty)] def reset_ids(self): """ Reset the ID column to be consecutive integers. """ self.id = np.arange(len(self)) + 1 @lazyproperty def sky(self): """ Calculate the sky background level. The local sky level is roughly estimated using the IRAF starfind calculation as the average value in the non-masked regions within the kernel footprint. """ skymask = ~self.kernel.mask.astype(bool) # 1=sky, 0=obj nsky = np.count_nonzero(skymask) axis = (1, 2) if nsky == 0.0: # pragma: no cover sky = (np.max(self.cutout_data_nosub, axis=axis) - np.max(self.cutout_convdata, axis=axis)) else: sky = (np.sum(self.cutout_data_nosub * skymask, axis=axis) / nsky) if self.unit is not None: sky <<= self.unit return sky def make_cutouts(self, data): cutouts = [] for xpos, ypos in self.xypos: cutouts.append(extract_array(data, self.cutout_shape, (ypos, xpos), fill_value=0.0)) value = np.array(cutouts) if self.unit is not None: value <<= self.unit return value @lazyproperty def cutout_data_nosub(self): return self.make_cutouts(self.data) @lazyproperty def cutout_data(self): data = ((self.cutout_data_nosub - self.sky[:, np.newaxis, np.newaxis]) * self.kernel.mask) # IRAF starfind discards negative pixels data[data < 0] = 0.0 return data @lazyproperty def cutout_convdata(self): # pragma: no cover return self.make_cutouts(self.convolved_data) @lazyproperty def npix(self): return np.count_nonzero(self.cutout_data, axis=(1, 2)) @lazyproperty def moments(self): return np.array([_moments(arr, order=1) for arr in self.cutout_data]) @lazyproperty def cutout_centroid(self): moments = self.moments # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) ycentroid = moments[:, 1, 0] / moments[:, 0, 0] xcentroid = moments[:, 0, 1] / moments[:, 0, 0] return np.transpose((ycentroid, xcentroid)) @lazyproperty def cutout_xcentroid(self): return np.transpose(self.cutout_centroid)[1] @lazyproperty def cutout_ycentroid(self): return np.transpose(self.cutout_centroid)[0] @lazyproperty def cutout_xorigin(self): return np.transpose(self.xypos)[0] - self.kernel.xradius @lazyproperty def cutout_yorigin(self): return np.transpose(self.xypos)[1] - self.kernel.yradius @lazyproperty def xcentroid(self): return self.cutout_xcentroid + self.cutout_xorigin @lazyproperty def ycentroid(self): return self.cutout_ycentroid + self.cutout_yorigin @lazyproperty def peak(self): peaks = [np.max(arr) for arr in self.cutout_data] return u.Quantity(peaks) if self.unit is not None else np.array(peaks) @lazyproperty def flux(self): fluxes = [np.sum(arr) for arr in self.cutout_data] if self.unit is not None: fluxes = u.Quantity(fluxes) else: fluxes = np.array(fluxes) return fluxes @lazyproperty def mag(self): flux = self.flux if isinstance(flux, u.Quantity): flux = flux.value return -2.5 * np.log10(flux) @lazyproperty def moments_central(self): moments = np.array([_moments_central(arr, center=(xcen_, ycen_), order=2) for arr, xcen_, ycen_ in zip(self.cutout_data, self.cutout_xcentroid, self.cutout_ycentroid, strict=True)]) with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return moments / self.moments[:, 0, 0][:, np.newaxis, np.newaxis] @lazyproperty def mu_sum(self): return self.moments_central[:, 0, 2] + self.moments_central[:, 2, 0] @lazyproperty def mu_diff(self): return self.moments_central[:, 0, 2] - self.moments_central[:, 2, 0] @lazyproperty def fwhm(self): return 2.0 * np.sqrt(np.log(2.0) * self.mu_sum) @lazyproperty def roundness(self): # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return (np.sqrt(self.mu_diff**2 + 4.0 * self.moments_central[:, 1, 1]**2) / self.mu_sum) @lazyproperty def sharpness(self): return self.fwhm / self.kernel.fwhm @lazyproperty def pa(self): pa = np.rad2deg(0.5 * np.arctan2(2.0 * self.moments_central[:, 1, 1], self.mu_diff)) return np.where(pa < 0, pa + 180, pa) def apply_filters(self): """ Filter the catalog. """ # remove all non-finite values - consider these non-detections attrs = ('xcentroid', 'ycentroid', 'sharpness', 'roundness', 'pa', 'sky', 'peak', 'flux') mask = np.count_nonzero(self.cutout_data, axis=(1, 2)) > 1 for attr in attrs: mask &= np.isfinite(getattr(self, attr)) newcat = self[mask] if len(newcat) == 0: warnings.warn('No sources were found.', NoDetectionsWarning) return None # keep sources that are within the sharpness, roundness, and # peakmax (inclusive) bounds mask = ((newcat.sharpness >= newcat.sharplo) & (newcat.sharpness <= newcat.sharphi) & (newcat.roundness >= newcat.roundlo) & (newcat.roundness <= newcat.roundhi)) if newcat.peakmax is not None: mask &= (newcat.peak <= newcat.peakmax) newcat = newcat[mask] if len(newcat) == 0: warnings.warn('Sources were found, but none pass the sharpness, ' 'roundness, or peakmax criteria', NoDetectionsWarning) return None return newcat def select_brightest(self): """ Sort the catalog by the brightest fluxes and select the top brightest sources. """ newcat = self if self.brightest is not None: idx = np.argsort(self.flux)[::-1][:self.brightest] newcat = self[idx] return newcat def apply_all_filters(self): """ Apply all filters, select the brightest, and reset the source IDs. """ cat = self.apply_filters() if cat is None: return None cat = cat.select_brightest() cat.reset_ids() return cat def to_table(self, columns=None): table = QTable() table.meta.update(_get_meta()) # keep table.meta type if columns is None: columns = self.default_columns for column in columns: table[column] = getattr(self, column) return table ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/detection/peakfinder.py0000644000175100001660000002261314755160622021660 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for finding local peaks in an astronomical image. """ import warnings import numpy as np from astropy.table import QTable from scipy.ndimage import maximum_filter from photutils.utils._misc import _get_meta from photutils.utils._parameters import as_pair from photutils.utils._quantity_helpers import process_quantities from photutils.utils._stats import nanmin from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['find_peaks'] def find_peaks(data, threshold, *, box_size=3, footprint=None, mask=None, border_width=None, npeaks=np.inf, centroid_func=None, error=None, wcs=None): """ Find local peaks in an image that are above a specified threshold value. Peaks are the maxima above the ``threshold`` within a local region. The local regions are defined by either the ``box_size`` or ``footprint`` parameters. ``box_size`` defines the local region around each pixel as a square box. ``footprint`` is a boolean array where `True` values specify the region shape. If multiple pixels within a local region have identical intensities, then the coordinates of all such pixels are returned. Otherwise, there will be only one peak pixel per local region. Thus, the defined region effectively imposes a minimum separation between peaks unless there are identical peaks within the region. If ``centroid_func`` is input, then it will be used to calculate a centroid within the defined local region centered on each detected peak pixel. In this case, the centroid will also be returned in the output table. Parameters ---------- data : array_like The 2D array of the image. threshold : float, scalar `~astropy.units.Quantity` or array_like The data value or pixel-wise data values to be used for the detection threshold. If ``data`` is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units as ``data``. A 2D ``threshold`` must have the same shape as ``data``. See `~photutils.segmentation.detect_threshold` for one way to create a ``threshold`` image. box_size : scalar or tuple, optional The size of the local region to search for peaks at every point in ``data``. If ``box_size`` is a scalar, then the region shape will be ``(box_size, box_size)``. Either ``box_size`` or ``footprint`` must be defined. If they are both defined, then ``footprint`` overrides ``box_size``. footprint : `~numpy.ndarray` of bools, optional A boolean array where `True` values describe the local footprint region within which to search for peaks at every point in ``data``. ``box_size=(n, m)`` is equivalent to ``footprint=np.ones((n, m))``. Either ``box_size`` or ``footprint`` must be defined. If they are both defined, then ``footprint`` overrides ``box_size``. mask : array_like, bool, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. border_width : int, array_like of int, or None, optional The width in pixels to exclude around the border of the ``data``. If ``border_width`` is a scalar then ``border_width`` will be applied to all sides. If ``border_width`` has two elements, they must be in ``(ny, nx)`` order. If `None`, then no border is excluded. The border width values must be non-negative integers. npeaks : int, optional The maximum number of peaks to return. When the number of detected peaks exceeds ``npeaks``, the peaks with the highest peak intensities will be returned. centroid_func : callable, optional A callable object (e.g., function or class) that is used to calculate the centroid of a 2D array. The ``centroid_func`` must accept a 2D `~numpy.ndarray`, have a ``mask`` keyword, and optionally an ``error`` keyword. The callable object must return a tuple of two 1D `~numpy.ndarray` objects, representing the x and y centroids, respectively. error : array_like, optional The 2D array of the 1-sigma errors of the input ``data``. ``error`` is used only if ``centroid_func`` is input (the ``error`` array is passed directly to the ``centroid_func``). If ``data`` is a `~astropy.units.Quantity` array, then ``error`` must have the same units as ``data``. wcs : `None` or WCS object, optional A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). If `None`, then the sky coordinates will not be returned in the output `~astropy.table.Table`. Returns ------- output : `~astropy.table.Table` or `None` A table containing the x and y pixel location of the peaks and their values. If ``centroid_func`` is input, then the table will also contain the centroid position. If no peaks are found then `None` is returned. Notes ----- By default, the returned pixel coordinates are the integer indices of the maximum pixel value within the input ``box_size`` or ``footprint`` (i.e., only the peak pixel is identified). However, a centroiding function can be input via the ``centroid_func`` keyword to compute centroid coordinates with subpixel precision within the input ``box_size`` or ``footprint``. """ arrays, unit = process_quantities((data, threshold, error), ('data', 'threshold', 'error')) data, threshold, error = arrays data = np.asanyarray(data) if np.all(data == data.flat[0]): warnings.warn('Input data is constant. No local peaks can be found.', NoDetectionsWarning) return None if not np.isscalar(threshold): threshold = np.asanyarray(threshold) if data.shape != threshold.shape: raise ValueError('A threshold array must have the same shape as ' 'the input data.') if border_width is not None: border_width = as_pair('border_width', border_width, lower_bound=(0, 0), upper_bound=data.shape) # remove NaN values to avoid runtime warnings nan_mask = np.isnan(data) if np.any(nan_mask): data = np.copy(data) # ndarray data[nan_mask] = nanmin(data) if footprint is not None: data_max = maximum_filter(data, footprint=footprint, mode='constant', cval=0.0) else: data_max = maximum_filter(data, size=box_size, mode='constant', cval=0.0) peak_goodmask = (data == data_max) # good pixels are True # Exclude peaks that are masked if mask is not None: mask = np.asanyarray(mask) if data.shape != mask.shape: raise ValueError('data and mask must have the same shape') peak_goodmask = np.logical_and(peak_goodmask, ~mask) # Exclude peaks that are too close to the border if border_width is not None: ny, nx = border_width if ny > 0: peak_goodmask[:ny, :] = False peak_goodmask[-ny:, :] = False if nx > 0: peak_goodmask[:, :nx] = False peak_goodmask[:, -nx:] = False # Exclude peaks below the threshold peak_goodmask = np.logical_and(peak_goodmask, (data > threshold)) y_peaks, x_peaks = peak_goodmask.nonzero() peak_values = data[y_peaks, x_peaks] if unit is not None: peak_values <<= unit nxpeaks = len(x_peaks) if nxpeaks > npeaks: idx = np.argsort(peak_values)[::-1][:npeaks] x_peaks = x_peaks[idx] y_peaks = y_peaks[idx] peak_values = peak_values[idx] if nxpeaks == 0: warnings.warn('No local peaks were found.', NoDetectionsWarning) return None # construct the output table ids = np.arange(len(x_peaks)) + 1 colnames = ['id', 'x_peak', 'y_peak', 'peak_value'] coldata = [ids, x_peaks, y_peaks, peak_values] table = QTable(coldata, names=colnames) table.meta.update(_get_meta()) # keep table.meta type if wcs is not None: skycoord_peaks = wcs.pixel_to_world(x_peaks, y_peaks) idx = table.colnames.index('y_peak') + 1 table.add_column(skycoord_peaks, name='skycoord_peak', index=idx) # perform centroiding if centroid_func is not None: # prevent circular import from photutils.centroids import centroid_sources if not callable(centroid_func): raise TypeError('centroid_func must be a callable object') x_centroids, y_centroids = centroid_sources( data, x_peaks, y_peaks, box_size=box_size, footprint=footprint, error=error, mask=mask, centroid_func=centroid_func) table['x_centroid'] = x_centroids table['y_centroid'] = y_centroids if wcs is not None: skycoord_centroids = wcs.pixel_to_world(x_centroids, y_centroids) idx = table.colnames.index('y_centroid') + 1 table.add_column(skycoord_centroids, name='skycoord_centroid', index=idx) return table ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/detection/starfinder.py0000644000175100001660000003671114755160622021715 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module implements the StarFinder class. """ import inspect import warnings import astropy.units as u import numpy as np from astropy.table import QTable from astropy.utils import lazyproperty from photutils.detection.core import StarFinderBase, _validate_brightest from photutils.utils._convolution import _filter_data from photutils.utils._misc import _get_meta from photutils.utils._moments import _moments, _moments_central from photutils.utils._quantity_helpers import process_quantities from photutils.utils.cutouts import _overlap_slices as overlap_slices from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['StarFinder'] class StarFinder(StarFinderBase): """ Detect stars in an image using a user-defined kernel. Parameters ---------- threshold : float The absolute image value above which to select sources. If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units. kernel : `~numpy.ndarray` A 2D array of the PSF kernel. min_separation : float, optional The minimum separation (in pixels) for detected objects. Note that large values may result in long run times. exclude_border : bool, optional Whether to exclude sources found within half the size of the convolution kernel from the image borders. brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``brightest`` is set to `None`, all objects will be selected. peakmax : float, None, optional The maximum allowed peak pixel value in an object. Objects with peak pixel values greater than ``peakmax`` 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 ``peakmax`` must have the same units. If ``peakmax`` is set to `None`, then no peak pixel value filtering will be performed. See Also -------- DAOStarFinder, IRAFStarFinder Notes ----- If the star finder is run on an image that is a `~astropy.units.Quantity` array, then ``threshold`` and ``peakmax`` must all have the same units as the image. For the convolution step, this routine sets pixels beyond the image borders to 0.0. The source properties are calculated using image moments. """ def __init__(self, threshold, kernel, min_separation=5.0, exclude_border=False, brightest=None, peakmax=None): # here we validate the units, but do not strip them inputs = (threshold, peakmax) names = ('threshold', 'peakmax') _ = process_quantities(inputs, names) self.threshold = threshold self.kernel = kernel if min_separation < 0: raise ValueError('min_separation must be >= 0') self.min_separation = min_separation self.exclude_border = exclude_border self.brightest = _validate_brightest(brightest) self.peakmax = peakmax def _get_raw_catalog(self, data, *, mask=None): kernel = self.kernel kernel /= np.max(kernel) # normalize max value to 1.0 denom = np.sum(kernel**2) - (np.sum(kernel)**2 / kernel.size) if denom > 0: kernel = (kernel - np.sum(kernel) / kernel.size) / denom convolved_data = _filter_data(data, kernel, mode='constant', fill_value=0.0, check_normalization=False) xypos = self._find_stars(convolved_data, kernel, self.threshold, min_separation=self.min_separation, mask=mask, exclude_border=self.exclude_border) if xypos is None: warnings.warn('No sources were found.', NoDetectionsWarning) return None return _StarFinderCatalog(data, xypos, self.kernel.shape, brightest=self.brightest, peakmax=self.peakmax) def find_stars(self, data, mask=None): """ Find stars in an astronomical image. Parameters ---------- data : 2D array_like The 2D image array. The image should be background-subtracted. mask : 2D bool array, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked pixels are ignored when searching for stars. Returns ------- table : `~astropy.table.QTable` or `None` A table of found objects with the following parameters: * ``id``: unique object identification number. * ``xcentroid, ycentroid``: object centroid. * ``fwhm``: object FWHM. * ``roundness``: object roundness. * ``pa``: object position angle (degrees counter clockwise from the positive x axis). * ``max_value``: the maximum pixel value in the source * ``flux``: the source instrumental flux. * ``mag``: the source instrumental magnitude calculated as ``-2.5 * log10(flux)``. `None` is returned if no stars are found or no stars meet the roundness and peakmax criteria. """ cat = self._get_raw_catalog(data, mask=mask) if cat is None: return None # apply all selection filters cat = cat.apply_all_filters() if cat is None: return None # create the output table return cat.to_table() class _StarFinderCatalog: """ Class to calculate the properties of each detected star. Parameters ---------- data : 2D `~numpy.ndarray` The 2D image. The image should be background-subtracted. xypos : Nx2 `~numpy.ndarray` A Nx2 array of (x, y) pixel coordinates denoting the central positions of the stars. shape : tuple of int The shape of the stars cutouts. The shape in both dimensions must be odd and match the shape of the smoothing kernel. brightest : int, None, optional The number of brightest objects to keep after sorting the source list by flux. If ``brightest`` is set to `None`, all objects will be selected. peakmax : float, None, optional The maximum allowed peak pixel value in an object. Objects with peak pixel values greater than ``peakmax`` 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 ``peakmax`` must have the same units. If ``peakmax`` is set to `None`, then no peak pixel value filtering will be performed. """ def __init__(self, data, xypos, shape, *, brightest=None, peakmax=None): # here we validate the units, but do not strip them inputs = (data, peakmax) names = ('data', 'peakmax') _ = process_quantities(inputs, names) self.data = data unit = data.unit if isinstance(data, u.Quantity) else None self.unit = unit self.xypos = np.atleast_2d(xypos) self.shape = shape self.brightest = brightest self.peakmax = peakmax self.id = np.arange(len(self)) + 1 self.default_columns = ('id', 'xcentroid', 'ycentroid', 'fwhm', 'roundness', 'pa', 'max_value', 'flux', 'mag') def __len__(self): return len(self.xypos) def __getitem__(self, index): # NOTE: we allow indexing/slicing of scalar (self.isscalar = True) # instances in order to perform catalog filtering even for # a single source newcls = object.__new__(self.__class__) # copy these attributes to the new instance init_attr = ('data', 'unit', 'shape', 'brightest', 'peakmax', 'default_columns') for attr in init_attr: setattr(newcls, attr, getattr(self, attr)) # xypos determines ordering and isscalar # NOTE: always keep as 2D array, even for a single source attr = 'xypos' value = getattr(self, attr)[index] setattr(newcls, attr, np.atleast_2d(value)) # index/slice the remaining attributes keys = set(self.__dict__.keys()) & set(self._lazyproperties) keys.add('id') scalar_index = np.isscalar(index) for key in keys: value = self.__dict__[key] # do not insert lazy attributes that are always scalar (e.g., # isscalar), i.e., not an array/list for each source if np.isscalar(value): continue if key in ('slices', 'cutout_data'): # lists instead of arrays # apply fancy indices to list properties value = np.array([*value, None], dtype=object)[:-1][index] value = [value] if scalar_index else value.tolist() else: # value is always at least a 1D array, even for a single # source value = np.atleast_1d(value[index]) newcls.__dict__[key] = value return newcls @lazyproperty def isscalar(self): """ Whether the instance is scalar (e.g., a single source). """ return self.xypos.shape == (1, 2) @property def _lazyproperties(self): """ Return all lazyproperties (even in superclasses). """ def islazyproperty(obj): return isinstance(obj, lazyproperty) return [i[0] for i in inspect.getmembers(self.__class__, predicate=islazyproperty)] def reset_ids(self): """ Reset the ID column to be consecutive integers. """ self.id = np.arange(len(self)) + 1 @lazyproperty def slices(self): slices = [] for xpos, ypos in self.xypos: slc, _ = overlap_slices(self.data.shape, self.shape, (ypos, xpos), mode='trim') slices.append(slc) return slices @lazyproperty def bbox_xmin(self): return np.array([slc[1].start for slc in self.slices]) @lazyproperty def bbox_ymin(self): return np.array([slc[0].start for slc in self.slices]) @lazyproperty def cutout_data(self): cutout = [] for slc in self.slices: cdata = self.data[slc] cdata[cdata < 0] = 0.0 # exclude negative pixels cutout.append(cdata) return cutout @lazyproperty def moments(self): return np.array([_moments(arr, order=1) for arr in self.cutout_data]) @lazyproperty def cutout_centroid(self): moments = self.moments # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) ycentroid = moments[:, 1, 0] / moments[:, 0, 0] xcentroid = moments[:, 0, 1] / moments[:, 0, 0] return np.transpose((ycentroid, xcentroid)) @lazyproperty def cutout_xcentroid(self): return np.transpose(self.cutout_centroid)[1] @lazyproperty def cutout_ycentroid(self): return np.transpose(self.cutout_centroid)[0] @lazyproperty def xcentroid(self): return self.cutout_xcentroid + self.bbox_xmin @lazyproperty def ycentroid(self): return self.cutout_ycentroid + self.bbox_ymin @lazyproperty def max_value(self): peaks = [np.max(arr) for arr in self.cutout_data] return u.Quantity(peaks) if self.unit is not None else np.array(peaks) @lazyproperty def flux(self): fluxes = [np.sum(arr) for arr in self.cutout_data] if self.unit is not None: fluxes = u.Quantity(fluxes) else: fluxes = np.array(fluxes) return fluxes @lazyproperty def mag(self): flux = self.flux if isinstance(flux, u.Quantity): flux = flux.value return -2.5 * np.log10(flux) @lazyproperty def moments_central(self): moments = np.array([_moments_central(arr, center=(xcen_, ycen_), order=2) for arr, xcen_, ycen_ in zip(self.cutout_data, self.cutout_xcentroid, self.cutout_ycentroid, strict=True)]) with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return moments / self.moments[:, 0, 0][:, np.newaxis, np.newaxis] @lazyproperty def mu_sum(self): return self.moments_central[:, 0, 2] + self.moments_central[:, 2, 0] @lazyproperty def mu_diff(self): return self.moments_central[:, 0, 2] - self.moments_central[:, 2, 0] @lazyproperty def fwhm(self): return 2.0 * np.sqrt(np.log(2.0) * self.mu_sum) @lazyproperty def roundness(self): with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) factor = self.mu_diff**2 + 4.0 * self.moments_central[:, 1, 1]**2 return np.sqrt(factor) / self.mu_sum @lazyproperty def pa(self): pa = np.rad2deg(0.5 * np.arctan2(2.0 * self.moments_central[:, 1, 1], self.mu_diff)) return np.where(pa < 0, pa + 180, pa) def apply_filters(self): """ Filter the catalog. """ # remove all non-finite values - consider these non-detections attrs = ('xcentroid', 'ycentroid', 'fwhm', 'roundness', 'pa', 'max_value', 'flux') mask = np.ones(len(self), dtype=bool) for attr in attrs: mask &= np.isfinite(getattr(self, attr)) newcat = self[mask] if len(newcat) == 0: warnings.warn('No sources were found.', NoDetectionsWarning) return None # keep sources with peak pixel values less than or equal to peakmax if newcat.peakmax is not None: mask = (newcat.max_value <= newcat.peakmax) newcat = newcat[mask] if len(newcat) == 0: warnings.warn('Sources were found, but none pass the peakmax ' 'criterion.', NoDetectionsWarning) return None return newcat def select_brightest(self): """ Sort the catalog by the brightest fluxes and select the top brightest sources. """ newcat = self if self.brightest is not None: idx = np.argsort(self.flux)[::-1][:self.brightest] newcat = self[idx] return newcat def apply_all_filters(self): """ Apply all filters, select the brightest, and reset the source ids. """ cat = self.apply_filters() if cat is None: return None cat = cat.select_brightest() cat.reset_ids() return cat def to_table(self, columns=None): table = QTable() table.meta.update(_get_meta()) # keep table.meta type if columns is None: columns = self.default_columns for column in columns: table[column] = getattr(self, column) return table ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.7009268 photutils-2.2.0/photutils/detection/tests/0000755000175100001660000000000014755160634020337 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/detection/tests/__init__.py0000644000175100001660000000000014755160622022433 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/detection/tests/conftest.py0000644000175100001660000000205714755160622022537 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Fixtures used in tests. """ import numpy as np import pytest from astropy.modeling.models import Gaussian2D from photutils.psf import CircularGaussianPRF, make_psf_model_image @pytest.fixture(name='kernel') def fixture_kernel(): size = 5 cen = (size - 1) / 2 y, x = np.mgrid[0:size, 0:size] g = Gaussian2D(1, cen, cen, 1.2, 1.2, theta=0) return g(x, y) @pytest.fixture(name='data') def fixture_data(): shape = (101, 101) model_shape = (11, 11) 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/detection/tests/test_daofinder.py0000644000175100001660000001777314755160622023717 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for DAOStarFinder. """ import astropy.units as u import numpy as np import pytest from numpy.testing import assert_array_equal from photutils.detection.daofinder import DAOStarFinder from photutils.utils.exceptions import NoDetectionsWarning class TestDAOStarFinder: def test_daostarfind(self, data): units = u.Jy threshold = 5.0 fwhm = 1.0 finder0 = DAOStarFinder(threshold, fwhm) finder1 = DAOStarFinder(threshold * units, fwhm) tbl0 = finder0(data) tbl1 = finder1(data << units) assert_array_equal(tbl0, tbl1) assert np.min(tbl0['flux']) > 150 # test that sources are returned with threshold = 0 finder = DAOStarFinder(0, fwhm) tbl = finder(data) assert len(tbl) == 25 def test_daofind_inputs(self): match = 'threshold must be a scalar value' with pytest.raises(ValueError, match=match): DAOStarFinder(threshold=np.ones((2, 2)), fwhm=3.0) 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' with pytest.raises(ValueError, match=match): DAOStarFinder(threshold=3.0, fwhm=-10) match = 'ratio must be positive and less or equal than 1' 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 = 'brightest must be >= 0' with pytest.raises(ValueError, match=match): DAOStarFinder(threshold=10, fwhm=1.5, brightest=-1) match = 'brightest must be an integer' with pytest.raises(ValueError, match=match): DAOStarFinder(threshold=10, fwhm=1.5, brightest=3.1) xycoords = np.array([[1, 2, 3, 4], [5, 6, 7, 8]]) match = 'xycoords must be shaped as a Nx2 array' with pytest.raises(ValueError, match=match): DAOStarFinder(threshold=10, fwhm=1.5, xycoords=xycoords) def test_daofind_nosources(self, data): match = 'No sources were found' with pytest.warns(NoDetectionsWarning, match=match): finder = DAOStarFinder(threshold=100, fwhm=2) tbl = finder(data) assert tbl is None with pytest.warns(NoDetectionsWarning, match=match): finder = DAOStarFinder(threshold=1, fwhm=2) tbl = finder(-data) assert tbl is None def test_daofind_exclude_border(self): data = np.zeros((9, 9)) data[0, 0] = 1 data[2, 2] = 1 data[4, 4] = 1 data[6, 6] = 1 finder0 = DAOStarFinder(threshold=0.1, fwhm=0.5, exclude_border=False) finder1 = DAOStarFinder(threshold=0.1, fwhm=0.5, exclude_border=True) tbl0 = finder0(data) tbl1 = finder1(data) assert len(tbl0) > len(tbl1) def test_daofind_sharpness(self, data): """ Sources found, but none pass the sharpness criteria. """ match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): finder = DAOStarFinder(threshold=1, fwhm=1.0, sharplo=1.0) tbl = finder(data) assert tbl is None def test_daofind_roundness(self, data): """ Sources found, but none pass the roundness criteria. """ match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): finder = DAOStarFinder(threshold=1, fwhm=1.0, roundlo=1.0) tbl = finder(data) assert tbl is None def test_daofind_peakmax(self, data): """ Sources found, but none pass the peakmax criteria. """ match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): finder = DAOStarFinder(threshold=1, fwhm=1.0, peakmax=1.0) tbl = finder(data) assert tbl is None def test_daofind_peakmax_filtering(self, data): """ Regression test that objects with peak >= peakmax are filtered out. """ peakmax = 8 finder0 = DAOStarFinder(threshold=1.0, fwhm=1.5, roundlo=-np.inf, roundhi=np.inf, sharplo=-np.inf, sharphi=np.inf) finder1 = DAOStarFinder(threshold=1.0, fwhm=1.5, roundlo=-np.inf, roundhi=np.inf, sharplo=-np.inf, sharphi=np.inf, peakmax=peakmax) tbl0 = finder0(data) tbl1 = finder1(data) assert len(tbl0) > len(tbl1) assert all(tbl1['peak'] < peakmax) def test_daofind_brightest_filtering(self, data): """ Regression test that only top ``brightest`` objects are selected. """ brightest = 10 finder = DAOStarFinder(threshold=1.0, fwhm=1.5, roundlo=-np.inf, roundhi=np.inf, sharplo=-np.inf, sharphi=np.inf, brightest=brightest) tbl = finder(data) assert len(tbl) == brightest # combined with peakmax peakmax = 8 finder = DAOStarFinder(threshold=1.0, fwhm=1.5, roundlo=-np.inf, roundhi=np.inf, sharplo=-np.inf, sharphi=np.inf, brightest=brightest, peakmax=peakmax) tbl = finder(data) assert len(tbl) == 5 def test_daofind_mask(self, data): """ Test DAOStarFinder with a mask. """ finder = DAOStarFinder(threshold=1.0, fwhm=1.5) mask = np.zeros(data.shape, dtype=bool) mask[0:50, :] = True tbl0 = finder(data) tbl1 = finder(data, mask=mask) assert len(tbl0) > len(tbl1) def test_xycoords(self, data): finder0 = DAOStarFinder(threshold=8.0, fwhm=2) tbl0 = finder0(data) xycoords = list(zip(tbl0['xcentroid'], tbl0['ycentroid'], strict=True)) xycoords = np.round(xycoords).astype(int) finder1 = DAOStarFinder(threshold=8.0, fwhm=2, xycoords=xycoords) tbl1 = finder1(data) assert_array_equal(tbl0, tbl1) def test_min_separation(self, data): threshold = 1.0 fwhm = 1.0 finder1 = DAOStarFinder(threshold, fwhm) tbl1 = finder1(data) finder2 = DAOStarFinder(threshold, fwhm, min_separation=10.0) tbl2 = finder2(data) assert len(tbl1) > len(tbl2) match = 'min_separation must be >= 0' with pytest.raises(ValueError, match=match): DAOStarFinder(threshold=10, fwhm=1.5, min_separation=-1.0) def test_single_detected_source(self, data): finder = DAOStarFinder(7.9, 2, brightest=1) mask = np.zeros(data.shape, dtype=bool) mask[0:50] = True tbl = finder(data, mask=mask) assert len(tbl) == 1 # test slicing with scalar catalog to improve coverage cat = finder._get_raw_catalog(data, mask=mask) assert cat.isscalar flux = cat.flux[0] # evaluate the flux so it can be sliced assert cat[0].flux == flux def test_interval_ends_included(self): # 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, roundlo=0, sharphi=1.407913491884342, peakmax=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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/detection/tests/test_irafstarfinder.py0000644000175100001660000001732714755160622024762 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for IRAFStarFinder. """ import astropy.units as u import numpy as np import pytest from numpy.testing import assert_array_equal from photutils.detection import IRAFStarFinder from photutils.psf import CircularGaussianPRF from photutils.utils.exceptions import NoDetectionsWarning class TestIRAFStarFinder: def test_irafstarfind(self, data): units = u.Jy threshold = 5.0 fwhm = 1.0 finder0 = IRAFStarFinder(threshold, fwhm) finder1 = IRAFStarFinder(threshold * units, fwhm) tbl0 = finder0(data) tbl1 = finder1(data << units) assert_array_equal(tbl0, tbl1) def test_irafstarfind_inputs(self): match = 'threshold must be a scalar value' with pytest.raises(TypeError, match=match): IRAFStarFinder(threshold=np.ones((2, 2)), fwhm=3.0) match = 'fwhm must be a scalar value' with pytest.raises(TypeError, match=match): IRAFStarFinder(threshold=3.0, fwhm=np.ones((2, 2))) match = 'brightest must be >= 0' with pytest.raises(ValueError, match=match): IRAFStarFinder(10, 1.5, brightest=-1) match = 'brightest must be an integer' with pytest.raises(ValueError, match=match): IRAFStarFinder(10, 1.5, brightest=3.1) xycoords = np.array([[1, 2, 3, 4], [5, 6, 7, 8]]) match = 'xycoords must be shaped as a Nx2 array' with pytest.raises(ValueError, match=match): IRAFStarFinder(threshold=10, fwhm=1.5, xycoords=xycoords) def test_irafstarfind_nosources(self): data = np.ones((3, 3)) match = 'No sources were found' with pytest.warns(NoDetectionsWarning, match=match): finder = IRAFStarFinder(threshold=10, fwhm=1) tbl = finder(data) assert tbl is None data = np.ones((5, 5)) data[2, 2] = 10.0 with pytest.warns(NoDetectionsWarning, match=match): finder = IRAFStarFinder(threshold=0.1, fwhm=0.1) tbl = finder(-data) assert tbl is None def test_irafstarfind_sharpness(self, data): """ Sources found, but none pass the sharpness criteria. """ match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): finder = IRAFStarFinder(threshold=1, fwhm=1.0, sharplo=2.0) tbl = finder(data) assert tbl is None def test_irafstarfind_roundness(self, data): """ Sources found, but none pass the roundness criteria. """ match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): finder = IRAFStarFinder(threshold=1, fwhm=1.0, roundlo=1.0) tbl = finder(data) assert tbl is None def test_irafstarfind_peakmax(self, data): """ Sources found, but none pass the peakmax criteria. """ match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): finder = IRAFStarFinder(threshold=1, fwhm=1.0, peakmax=1.0) tbl = finder(data) assert tbl is None def test_irafstarfind_peakmax_filtering(self, data): """ Regression test that objects with peak >= peakmax are filtered out. """ peakmax = 8 finder0 = IRAFStarFinder(threshold=1.0, fwhm=2, roundlo=-np.inf, roundhi=np.inf, sharplo=-np.inf, sharphi=np.inf) finder1 = IRAFStarFinder(threshold=1.0, fwhm=2, roundlo=-np.inf, roundhi=np.inf, sharplo=-np.inf, sharphi=np.inf, peakmax=peakmax) tbl0 = finder0(data) tbl1 = finder1(data) assert len(tbl0) > len(tbl1) assert all(tbl1['peak'] < peakmax) def test_irafstarfind_brightest_filtering(self, data): """ Regression test that only top ``brightest`` objects are selected. """ brightest = 10 finder = IRAFStarFinder(threshold=1.0, fwhm=2, roundlo=-np.inf, roundhi=np.inf, sharplo=-np.inf, sharphi=np.inf, brightest=brightest) tbl = finder(data) assert len(tbl) == brightest def test_irafstarfind_mask(self, data): """ Test IRAFStarFinder with a mask. """ finder = IRAFStarFinder(threshold=1.0, fwhm=1.5) mask = np.zeros(data.shape, dtype=bool) mask[0:50, :] = True tbl0 = finder(data) tbl1 = finder(data, mask=mask) assert len(tbl0) > len(tbl1) def test_xycoords(self, data): finder0 = IRAFStarFinder(threshold=8.0, fwhm=2) tbl0 = finder0(data) xycoords = list(zip(tbl0['xcentroid'], tbl0['ycentroid'], strict=True)) xycoords = np.round(xycoords).astype(int) finder1 = IRAFStarFinder(threshold=8.0, fwhm=2, xycoords=xycoords) tbl1 = finder1(data) assert_array_equal(tbl0, tbl1) def test_min_separation(self, data): threshold = 1.0 fwhm = 1.0 finder1 = IRAFStarFinder(threshold, fwhm) tbl1 = finder1(data) finder2 = IRAFStarFinder(threshold, fwhm, min_separation=3.0) tbl2 = finder2(data) assert np.all(tbl1 == tbl2) finder3 = IRAFStarFinder(threshold, fwhm, min_separation=10.0) tbl3 = finder3(data) assert len(tbl2) > len(tbl3) match = 'min_separation must be >= 0' with pytest.raises(ValueError, match=match): IRAFStarFinder(threshold=10, fwhm=1.5, min_separation=-1.0) def test_single_detected_source(self, data): finder = IRAFStarFinder(8.4, 2, brightest=1) mask = np.zeros(data.shape, dtype=bool) mask[0:50] = True tbl = finder(data, mask=mask) assert len(tbl) == 1 # test slicing with scalar catalog to improve coverage cat = finder._get_raw_catalog(data, mask=mask) assert cat.isscalar flux = cat.flux[0] # evaluate the flux so it can be sliced assert cat[0].flux == flux def test_all_border_sources(self): 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, roundlo=-0.1, 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): # 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, roundlo=0, peakmax=0.8 ) tbl = finder.find_stars(data) assert len(tbl) == 1 assert tbl[0]['roundness'] < 1.e-15 assert tbl[0]['peak'] == 0.8 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/detection/tests/test_peakfinder.py0000644000175100001660000001617014755160622024062 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the peakfinder module. """ import astropy.units as u import numpy as np import pytest from astropy.tests.helper import assert_quantity_allclose from numpy.testing import assert_array_equal, assert_equal from photutils.centroids import centroid_com from photutils.datasets import make_gwcs, make_wcs from photutils.detection import find_peaks from photutils.utils._optional_deps import HAS_GWCS 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_maskshape(self, data): """ Test if make 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_npeaks(self, data): """ Test npeaks. """ tbl = find_peaks(data, 0.1, box_size=3, npeaks=1) assert len(tbl) == 1 def test_border_width(self, data): """ Test border exclusion. """ tbl0 = find_peaks(data, 0.1, box_size=3) tbl1 = find_peaks(data, 0.1, box_size=3, border_width=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_box_size_int(self, data): """ Test non-integer box_size. """ tbl1 = find_peaks(data, 0.1, box_size=5.0) tbl2 = find_peaks(data, 0.1, box_size=5.5) assert_array_equal(tbl1, tbl2) def test_centroid_func_callable(self, data): """ Test that centroid_func is callable. """ 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_wcs(self, data): """ Test with astropy WCS. """ columns = ['skycoord_peak', 'skycoord_centroid'] fits_wcs = make_wcs(data.shape) tbl = find_peaks(data, 1, wcs=fits_wcs, centroid_func=centroid_com) for column in columns: assert column in tbl.colnames assert tbl.colnames == ['id', 'x_peak', 'y_peak', 'skycoord_peak', 'peak_value', 'x_centroid', 'y_centroid', 'skycoord_centroid'] @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') def test_gwcs(self, data): """ Test with gwcs. """ columns = ['skycoord_peak', 'skycoord_centroid'] gwcs_obj = make_gwcs(data.shape) tbl = find_peaks(data, 1, wcs=gwcs_obj, centroid_func=centroid_com) for column in columns: assert column in tbl.colnames @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') def test_wcs_values(self, data): fits_wcs = make_wcs(data.shape) gwcs_obj = make_gwcs(data.shape) tbl1 = find_peaks(data, 1, wcs=fits_wcs, centroid_func=centroid_com) tbl2 = find_peaks(data, 1, wcs=gwcs_obj, centroid_func=centroid_com) columns = ['skycoord_peak', 'skycoord_centroid'] for column in columns: assert_quantity_allclose(tbl1[column].ra, tbl2[column].ra) assert_quantity_allclose(tbl1[column].dec, tbl2[column].dec) def test_constant_array(self): """ Test for empty output table when data is constant. """ data = np.ones((10, 10)) match = 'Input data is constant' with pytest.warns(NoDetectionsWarning, match=match): tbl = find_peaks(data, 0.0) assert tbl is None def test_no_peaks(self, data): """ Tests for when no peaks are found. """ fits_wcs = make_wcs(data.shape) match = 'No local peaks were found' with pytest.warns(NoDetectionsWarning, match=match): tbl = find_peaks(data, 10000) assert tbl is None with pytest.warns(NoDetectionsWarning, match=match): tbl = find_peaks(data, 100000, centroid_func=centroid_com) assert tbl is None with pytest.warns(NoDetectionsWarning, match=match): tbl = find_peaks(data, 100000, wcs=fits_wcs) assert tbl is None with pytest.warns(NoDetectionsWarning, match=match): tbl = find_peaks(data, 100000, wcs=fits_wcs, centroid_func=centroid_com) assert tbl is None def test_data_nans(self, data): """ Test that data with NaNs does not issue Runtime warning. """ data = np.copy(data) data[50:, :] = np.nan find_peaks(data, 0.1) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/detection/tests/test_starfinder.py0000644000175100001660000001130214755160622024103 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for StarFinder. """ import astropy.units as u import numpy as np import pytest from astropy.table import Table from numpy.testing import assert_equal from photutils.detection import StarFinder from photutils.utils.exceptions import NoDetectionsWarning class TestStarFinder: def test_starfind(self, data, kernel): finder1 = StarFinder(1, kernel) finder2 = StarFinder(10, kernel) tbl1 = finder1(data) tbl2 = finder2(data) assert isinstance(tbl1, Table) assert len(tbl1) == 25 assert len(tbl2) == 9 # test with units unit = u.Jy finder3 = StarFinder(1 * unit, kernel) tbl3 = finder3(data << unit) assert isinstance(tbl3, Table) assert len(tbl3) == 25 assert tbl3['flux'].unit == unit assert tbl3['max_value'].unit == unit for col in tbl3.colnames: if col not in ('flux', 'max_value'): assert_equal(tbl3[col], tbl1[col]) def test_inputs(self, kernel): match = 'min_separation must be >= 0' with pytest.raises(ValueError, match=match): StarFinder(1, kernel, min_separation=-1) match = 'brightest must be >= 0' with pytest.raises(ValueError, match=match): StarFinder(1, kernel, brightest=-1) match = 'brightest must be an integer' with pytest.raises(ValueError, match=match): StarFinder(1, kernel, brightest=3.1) def test_exclude_border(self, data, kernel): data = np.zeros((12, 12)) data[0:2, 0:2] = 1 data[9:12, 9:12] = 1 kernel = np.ones((3, 3)) finder0 = StarFinder(1, kernel, exclude_border=False) finder1 = StarFinder(1, kernel, exclude_border=True) tbl0 = finder0(data) tbl1 = finder1(data) assert len(tbl0) > len(tbl1) def test_nosources(self, data, kernel): match = 'No sources were found' with pytest.warns(NoDetectionsWarning, match=match): finder = StarFinder(100, kernel) tbl = finder(data) assert tbl is None data = np.ones((5, 5)) data[2, 2] = 10.0 with pytest.warns(NoDetectionsWarning, match=match): finder = StarFinder(1, kernel) tbl = finder(-data) assert tbl is None def test_min_separation(self, data, kernel): finder1 = StarFinder(1, kernel, min_separation=0) finder2 = StarFinder(1, kernel, min_separation=10) tbl1 = finder1(data) tbl2 = finder2(data) assert len(tbl1) == 25 assert len(tbl2) == 20 def test_peakmax(self, data, kernel): finder1 = StarFinder(1, kernel, peakmax=None) finder2 = StarFinder(1, kernel, peakmax=11) tbl1 = finder1(data) tbl2 = finder2(data) assert len(tbl1) == 25 assert len(tbl2) == 16 match = 'Sources were found, but none pass' with pytest.warns(NoDetectionsWarning, match=match): starfinder = StarFinder(10, kernel, peakmax=5) tbl = starfinder(data) assert tbl is None def test_peakmax_limit(self): """ Test that the peakmax 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, peakmax=0.8) tbl = finder.find_stars(data) assert len(tbl) == 1 assert tbl[0]['max_value'] == 0.8 def test_brightest(self, data, kernel): finder = StarFinder(1, kernel, brightest=10) tbl = finder(data) assert len(tbl) == 10 fluxes = tbl['flux'] assert fluxes[0] == np.max(fluxes) def test_single_detected_source(self, data, kernel): finder = StarFinder(11.5, kernel, brightest=1) mask = np.zeros(data.shape, dtype=bool) mask[0:50] = True tbl = finder(data, mask=mask) assert len(tbl) == 1 # test slicing with scalar catalog to improve coverage cat = finder._get_raw_catalog(data, mask=mask) assert cat.isscalar flux = cat.flux[0] # evaluate the flux so it can be sliced assert cat[0].flux == flux def test_mask(self, data, kernel): starfinder = StarFinder(1, kernel) mask = np.zeros(data.shape, dtype=bool) mask[0:50] = True tbl1 = starfinder(data) tbl2 = starfinder(data, mask=mask) assert len(tbl1) == 25 assert len(tbl2) == 13 assert min(tbl2['ycentroid']) > 50 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.7009268 photutils-2.2.0/photutils/extern/0000755000175100001660000000000014755160634016524 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/extern/__init__.py0000644000175100001660000000024114755160622020627 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools that are bundled with the package but are external to it. """ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/extern/biweight.py0000644000175100001660000005136514755160622020707 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module include fixes to the astropy.stats.biweight module applied in https://github.com/astropy/astropy/pull/16964. It can be removed when the minimum supported version of astropy is 6.1.3+. This module contains functions for computing robust statistics using Tukey's biweight function. """ from __future__ import annotations from typing import TYPE_CHECKING import numpy as np from photutils.utils._optional_deps import HAS_BOTTLENECK from photutils.utils._stats import nanmedian, nansum if TYPE_CHECKING: from collections.abc import Callable from numpy.typing import ArrayLike, NDArray __all__ = ['biweight_location', 'biweight_scale', 'biweight_midvariance', 'median_absolute_deviation'] def _stat_functions( data: ArrayLike, ignore_nan: bool | None = False, ) -> tuple[Callable[..., NDArray[float]], Callable[..., NDArray[float]]]: # TODO: typing: update return Callables with custom callback protocol (https://mypy.readthedocs.io/en/stable/protocols.html#callback-protocols) if isinstance(data, np.ma.MaskedArray): median_func = np.ma.median sum_func = np.ma.sum elif ignore_nan: median_func = nanmedian sum_func = nansum else: median_func = np.median sum_func = np.sum return median_func, sum_func def biweight_location( data: ArrayLike, c: float = 6.0, M: float | ArrayLike | None = None, axis: int | tuple[int, ...] | None = None, *, ignore_nan: bool | None = False, ) -> float | NDArray[float]: r""" Compute the biweight location. The biweight location is a robust statistic for determining the central location of a distribution. It is given by: .. math:: \zeta_{biloc}= M + \frac{\sum_{|u_i|<1} \ (x_i - M) (1 - u_i^2)^2} {\sum_{|u_i|<1} \ (1 - u_i^2)^2} where :math:`x` is the input data, :math:`M` is the sample median (or the input initial location guess) and :math:`u_i` is given by: .. math:: u_{i} = \frac{(x_i - M)}{c * MAD} where :math:`c` is the tuning constant and :math:`MAD` is the `median absolute deviation `_. The biweight location tuning constant ``c`` is typically 6.0 (the default). If :math:`MAD` is zero, then the median will be returned. Parameters ---------- data : array-like Input array or object that can be converted to an array. ``data`` can be a `~numpy.ma.MaskedArray`. c : float, optional Tuning constant for the biweight estimator (default = 6.0). M : float or array-like, optional Initial guess for the location. If ``M`` is a scalar value, then its value will be used for the entire array (or along each ``axis``, if specified). If ``M`` is an array, then its must be an array containing the initial location estimate along each ``axis`` of the input array. If `None` (default), then the median of the input array will be used (or along each ``axis``, if specified). axis : int or tuple of int, optional The axis or axes along which the biweight locations are computed. If `None` (default), then the biweight location of the flattened input array will be computed. ignore_nan : bool, optional Whether to ignore NaN values in the input ``data``. Returns ------- biweight_location : float or `~numpy.ndarray` The biweight location of the input data. If ``axis`` is `None` then a scalar will be returned, otherwise a `~numpy.ndarray` will be returned. See Also -------- biweight_scale, biweight_midvariance, biweight_midcovariance References ---------- .. [1] Beers, Flynn, and Gebhardt (1990; AJ 100, 32) (https://ui.adsabs.harvard.edu/abs/1990AJ....100...32B) .. [2] https://www.itl.nist.gov/div898/software/dataplot/refman2/auxillar/biwloc.htm Examples -------- Generate random variates from a Gaussian distribution and return the biweight location of the distribution: >>> import numpy as np >>> from astropy.stats import biweight_location >>> rand = np.random.default_rng(12345) >>> biloc = biweight_location(rand.standard_normal(1000)) >>> print(biloc) # doctest: +FLOAT_CMP 0.01535330525461019 """ median_func, sum_func = _stat_functions(data, ignore_nan=ignore_nan) if isinstance(data, np.ma.MaskedArray) and ignore_nan: data = np.ma.masked_where(np.isnan(data), data, copy=True) data = np.asanyarray(data).astype(np.float64) if M is None: M = median_func(data, axis=axis) if axis is not None: M = np.expand_dims(M, axis=axis) # set up the differences d = data - M # set up the weighting mad = median_absolute_deviation(data, axis=axis, ignore_nan=ignore_nan) # np.ndim(mad) = 0 means axis is None or contains all axes # mad = 0 means data is constant or mostly constant # mad = np.nan means data contains NaNs and ignore_nan=False if np.ndim(mad) == 0 and (mad == 0.0 or np.isnan(mad)): return M.squeeze(axis=axis) if axis is not None: mad = np.expand_dims(mad, axis=axis) with np.errstate(divide='ignore', invalid='ignore'): u = d / (c * mad) # now remove the outlier points # ignore RuntimeWarnings for comparisons with NaN data values with np.errstate(invalid='ignore'): mask = np.abs(u) >= 1 u = (1 - u**2) ** 2 u[mask] = 0 # If mad == 0 along the specified ``axis`` in the input data, return # the median value along that axis. # Ignore RuntimeWarnings for divide by zero with np.errstate(divide='ignore', invalid='ignore'): value = M.squeeze(axis=axis) + ( sum_func(d * u, axis=axis) / sum_func(u, axis=axis) ) if np.isscalar(value): return value where_func = np.where if isinstance(data, np.ma.MaskedArray): where_func = np.ma.where # return MaskedArray return where_func(mad.squeeze(axis=axis) == 0, M.squeeze(axis=axis), value) def biweight_scale( data: ArrayLike, c: float = 9.0, M: float | ArrayLike | None = None, axis: int | tuple[int, ...] | None = None, modify_sample_size: bool | None = False, *, ignore_nan: bool | None = False, ) -> float | NDArray[float]: r""" Compute the biweight scale. The biweight scale is a robust statistic for determining the standard deviation of a distribution. It is the square root of the `biweight midvariance `_. It is given by: .. math:: \zeta_{biscl} = \sqrt{n} \ \frac{\sqrt{\sum_{|u_i| < 1} \ (x_i - M)^2 (1 - u_i^2)^4}} {|(\sum_{|u_i| < 1} \ (1 - u_i^2) (1 - 5u_i^2))|} where :math:`x` is the input data, :math:`M` is the sample median (or the input location) and :math:`u_i` is given by: .. math:: u_{i} = \frac{(x_i - M)}{c * MAD} where :math:`c` is the tuning constant and :math:`MAD` is the `median absolute deviation `_. The biweight midvariance tuning constant ``c`` is typically 9.0 (the default). If :math:`MAD` is zero, then zero will be returned. For the standard definition of biweight scale, :math:`n` is the total number of points in the array (or along the input ``axis``, if specified). That definition is used if ``modify_sample_size`` is `False`, which is the default. However, if ``modify_sample_size = True``, then :math:`n` is the number of points for which :math:`|u_i| < 1` (i.e. the total number of non-rejected values), i.e. .. math:: n = \sum_{|u_i| < 1} \ 1 which results in a value closer to the true standard deviation for small sample sizes or for a large number of rejected values. Parameters ---------- data : array-like Input array or object that can be converted to an array. ``data`` can be a `~numpy.ma.MaskedArray`. c : float, optional Tuning constant for the biweight estimator (default = 9.0). M : float or array-like, optional The location estimate. If ``M`` is a scalar value, then its value will be used for the entire array (or along each ``axis``, if specified). If ``M`` is an array, then its must be an array containing the location estimate along each ``axis`` of the input array. If `None` (default), then the median of the input array will be used (or along each ``axis``, if specified). axis : int or tuple of int, optional The axis or axes along which the biweight scales are computed. If `None` (default), then the biweight scale of the flattened input array will be computed. modify_sample_size : bool, optional If `False` (default), then the sample size used is the total number of elements in the array (or along the input ``axis``, if specified), which follows the standard definition of biweight scale. If `True`, then the sample size is reduced to correct for any rejected values (i.e. the sample size used includes only the non-rejected values), which results in a value closer to the true standard deviation for small sample sizes or for a large number of rejected values. ignore_nan : bool, optional Whether to ignore NaN values in the input ``data``. Returns ------- biweight_scale : float or `~numpy.ndarray` The biweight scale of the input data. If ``axis`` is `None` then a scalar will be returned, otherwise a `~numpy.ndarray` will be returned. See Also -------- biweight_midvariance, biweight_midcovariance, biweight_location, astropy.stats.mad_std, astropy.stats.median_absolute_deviation References ---------- .. [1] Beers, Flynn, and Gebhardt (1990; AJ 100, 32) (https://ui.adsabs.harvard.edu/abs/1990AJ....100...32B) .. [2] https://www.itl.nist.gov/div898/software/dataplot/refman2/auxillar/biwscale.htm Examples -------- Generate random variates from a Gaussian distribution and return the biweight scale of the distribution: >>> import numpy as np >>> from astropy.stats import biweight_scale >>> rand = np.random.default_rng(12345) >>> biscl = biweight_scale(rand.standard_normal(1000)) >>> print(biscl) # doctest: +FLOAT_CMP 1.0239311812635818 """ return np.sqrt( biweight_midvariance( data, c=c, M=M, axis=axis, modify_sample_size=modify_sample_size, ignore_nan=ignore_nan, ) ) def biweight_midvariance( data: ArrayLike, c: float = 9.0, M: float | ArrayLike | None = None, axis: int | tuple[int, ...] | None = None, modify_sample_size: bool | None = False, *, ignore_nan: bool | None = False, ) -> float | NDArray[float]: r""" Compute the biweight midvariance. The biweight midvariance is a robust statistic for determining the variance of a distribution. Its square root is a robust estimator of scale (i.e. standard deviation). It is given by: .. math:: \zeta_{bivar} = n \ \frac{\sum_{|u_i| < 1} \ (x_i - M)^2 (1 - u_i^2)^4} {(\sum_{|u_i| < 1} \ (1 - u_i^2) (1 - 5u_i^2))^2} where :math:`x` is the input data, :math:`M` is the sample median (or the input location) and :math:`u_i` is given by: .. math:: u_{i} = \frac{(x_i - M)}{c * MAD} where :math:`c` is the tuning constant and :math:`MAD` is the `median absolute deviation `_. The biweight midvariance tuning constant ``c`` is typically 9.0 (the default). If :math:`MAD` is zero, then zero will be returned. For the standard definition of `biweight midvariance `_, :math:`n` is the total number of points in the array (or along the input ``axis``, if specified). That definition is used if ``modify_sample_size`` is `False`, which is the default. However, if ``modify_sample_size = True``, then :math:`n` is the number of points for which :math:`|u_i| < 1` (i.e. the total number of non-rejected values), i.e. .. math:: n = \sum_{|u_i| < 1} \ 1 which results in a value closer to the true variance for small sample sizes or for a large number of rejected values. Parameters ---------- data : array-like Input array or object that can be converted to an array. ``data`` can be a `~numpy.ma.MaskedArray`. c : float, optional Tuning constant for the biweight estimator (default = 9.0). M : float or array-like, optional The location estimate. If ``M`` is a scalar value, then its value will be used for the entire array (or along each ``axis``, if specified). If ``M`` is an array, then its must be an array containing the location estimate along each ``axis`` of the input array. If `None` (default), then the median of the input array will be used (or along each ``axis``, if specified). axis : int or tuple of int, optional The axis or axes along which the biweight midvariances are computed. If `None` (default), then the biweight midvariance of the flattened input array will be computed. modify_sample_size : bool, optional If `False` (default), then the sample size used is the total number of elements in the array (or along the input ``axis``, if specified), which follows the standard definition of biweight midvariance. If `True`, then the sample size is reduced to correct for any rejected values (i.e. the sample size used includes only the non-rejected values), which results in a value closer to the true variance for small sample sizes or for a large number of rejected values. ignore_nan : bool, optional Whether to ignore NaN values in the input ``data``. Returns ------- biweight_midvariance : float or `~numpy.ndarray` The biweight midvariance of the input data. If ``axis`` is `None` then a scalar will be returned, otherwise a `~numpy.ndarray` will be returned. See Also -------- biweight_midcovariance, biweight_midcorrelation, astropy.stats.mad_std, astropy.stats.median_absolute_deviation References ---------- .. [1] https://en.wikipedia.org/wiki/Robust_measures_of_scale#The_biweight_midvariance .. [2] Beers, Flynn, and Gebhardt (1990; AJ 100, 32) (https://ui.adsabs.harvard.edu/abs/1990AJ....100...32B) Examples -------- Generate random variates from a Gaussian distribution and return the biweight midvariance of the distribution: >>> import numpy as np >>> from astropy.stats import biweight_midvariance >>> rand = np.random.default_rng(12345) >>> bivar = biweight_midvariance(rand.standard_normal(1000)) >>> print(bivar) # doctest: +FLOAT_CMP 1.0484350639638342 """ median_func, sum_func = _stat_functions(data, ignore_nan=ignore_nan) if isinstance(data, np.ma.MaskedArray) and ignore_nan: data = np.ma.masked_where(np.isnan(data), data, copy=True) data = np.asanyarray(data).astype(np.float64) if M is None: M = median_func(data, axis=axis) if axis is not None: M = np.expand_dims(M, axis=axis) # set up the differences d = data - M # set up the weighting mad = median_absolute_deviation(data, axis=axis, ignore_nan=ignore_nan) # np.ndim(mad) = 0 means axis is None or contains all axes # mad = 0 means data is constant or mostly constant # mad = np.nan means data contains NaNs and ignore_nan=False if np.ndim(mad) == 0 and (mad == 0.0 or np.isnan(mad)): return mad**2 # variance units if axis is not None: mad = np.expand_dims(mad, axis=axis) with np.errstate(divide='ignore', invalid='ignore'): u = d / (c * mad) # now remove the outlier points # ignore RuntimeWarnings for comparisons with NaN data values with np.errstate(invalid='ignore'): mask = np.abs(u) < 1 if isinstance(mask, np.ma.MaskedArray): mask = mask.filled(fill_value=False) # exclude masked data values u = u**2 if modify_sample_size: n = sum_func(mask, axis=axis) else: # set good values to 1, bad values to 0 include_mask = np.ones(data.shape) if isinstance(data, np.ma.MaskedArray): include_mask[data.mask] = 0 if ignore_nan: include_mask[np.isnan(data)] = 0 n = np.sum(include_mask, axis=axis) f1 = d * d * (1.0 - u) ** 4 f1[~mask] = 0.0 f1 = sum_func(f1, axis=axis) f2 = (1.0 - u) * (1.0 - 5.0 * u) f2[~mask] = 0.0 f2 = np.abs(np.sum(f2, axis=axis)) ** 2 # If mad == 0 along the specified ``axis`` in the input data, return # 0.0 along that axis. # Ignore RuntimeWarnings for divide by zero. with np.errstate(divide='ignore', invalid='ignore'): value = n * f1 / f2 if np.isscalar(value): return value where_func = np.where if isinstance(data, np.ma.MaskedArray): where_func = np.ma.where # return MaskedArray return where_func(mad.squeeze(axis=axis) == 0, 0.0, value) def median_absolute_deviation(data, axis=None, func=None, ignore_nan=False): """ Calculate the median absolute deviation (MAD). The MAD is defined as ``median(abs(a - median(a)))``. Parameters ---------- data : array-like Input array or object that can be converted to an array. axis : None, int, or tuple of int, optional The axis or axes along which the MADs are computed. The default (`None`) is to compute the MAD of the flattened array. func : callable, optional The function used to compute the median. Defaults to `numpy.ma.median` for masked arrays, otherwise to `numpy.median`. ignore_nan : bool, optional Ignore NaN values (treat them as if they are not in the array) when computing the median. This will use `numpy.ma.median` if ``axis`` is specified, or `numpy.nanmedian` if ``axis==None`` and numpy's version is >1.10 because nanmedian is slightly faster in this case. Returns ------- mad : float or `~numpy.ndarray` The median absolute deviation of the input array. If ``axis`` is `None` then a scalar will be returned, otherwise a `~numpy.ndarray` will be returned. Examples -------- Generate random variates from a Gaussian distribution and return the median absolute deviation for that distribution:: >>> import numpy as np >>> from astropy.stats import median_absolute_deviation >>> rand = np.random.default_rng(12345) >>> from numpy.random import randn >>> mad = median_absolute_deviation(rand.standard_normal(1000)) >>> print(mad) # doctest: +FLOAT_CMP 0.6829504282771885 See Also -------- mad_std """ if func is None: # Check if the array has a mask and if so use np.ma.median # See https://github.com/numpy/numpy/issues/7330 why using np.ma.median # for normal arrays should not be done (summary: np.ma.median always # returns an masked array even if the result should be scalar). (#4658) if isinstance(data, np.ma.MaskedArray): is_masked = True func = np.ma.median if ignore_nan: data = np.ma.masked_where(np.isnan(data), data, copy=True) elif ignore_nan: is_masked = False func = nanmedian else: is_masked = False func = np.median # drops units if result is NaN else: is_masked = None data = np.asanyarray(data) # np.nanmedian has `keepdims`, which is a good option if we're not allowing # user-passed functions here data_median = func(data, axis=axis) # broadcast the median array before subtraction if axis is not None: data_median = np.expand_dims(data_median, axis=axis) if HAS_BOTTLENECK: result = func(np.abs(data - data_median), axis=axis) else: result = func(np.abs(data - data_median), axis=axis, overwrite_input=True) if axis is None and np.ma.isMaskedArray(result): # return scalar version result = result.item() elif np.ma.isMaskedArray(result) and not is_masked: # if the input array was not a masked array, we don't want to return a # masked array result = result.filled(fill_value=np.nan) return result ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.7019267 photutils-2.2.0/photutils/geometry/0000755000175100001660000000000014755160634017052 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/geometry/__init__.py0000644000175100001660000000077014755160622021164 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage provides low-level geometry functions used by aperture photometry to calculate the overlap of aperture shapes with a pixel grid. These functions are not intended to be used directly by users, but are used by the higher-level `photutils.aperture` tools. """ from .circular_overlap import * # noqa: F401, F403 from .elliptical_overlap import * # noqa: F401, F403 from .rectangular_overlap import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/geometry/circular_overlap.pyx0000644000175100001660000002031314755160622023144 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3 """ The functions defined here allow one to determine the exact area of overlap of a rectangle and a circle (written by Thomas Robitaille). """ import numpy as np cimport numpy as np __all__ = ['circular_overlap_grid'] cdef extern from "math.h": double asin(double x) double sin(double x) double sqrt(double x) DTYPE = np.float64 ctypedef np.float64_t DTYPE_t # NOTE: Here we need to make sure we use cimport to import the C functions from # core (since these were defined with cdef). This also requires the core.pxd # file to exist with the function signatures. from .core cimport area_arc, area_triangle, floor_sqrt def circular_overlap_grid(double xmin, double xmax, double ymin, double ymax, int nx, int ny, double r, int use_exact, int subpixels): """ circular_overlap_grid(xmin, xmax, ymin, ymax, nx, ny, r, use_exact, subpixels) Area of overlap between a circle and a pixel grid. The circle is centered on the origin. Parameters ---------- xmin, xmax, ymin, ymax : float Extent of the grid in the x and y direction. nx, ny : int Grid dimensions. r : float The radius of the circle. use_exact : 0 or 1 If ``1`` calculates exact overlap, if ``0`` uses ``subpixel`` number of subpixels to calculate the overlap. subpixels : int Each pixel resampled by this factor in each dimension, thus each pixel is divided into ``subpixels ** 2`` subpixels. Returns ------- frac : `~numpy.ndarray` (float) 2-d array of shape (ny, nx) giving the fraction of the overlap. """ cdef unsigned int i, j cdef double x, y, dx, dy, d, pixel_radius cdef double bxmin, bxmax, bymin, bymax cdef double pxmin, pxcen, pxmax, pymin, pycen, pymax # Define output array cdef np.ndarray[DTYPE_t, ndim=2] frac = np.zeros([ny, nx], dtype=DTYPE) # Find the width of each element in x and y dx = (xmax - xmin) / nx dy = (ymax - ymin) / ny # Find the radius of a single pixel pixel_radius = 0.5 * sqrt(dx * dx + dy * dy) # Define bounding box bxmin = -r - 0.5 * dx bxmax = +r + 0.5 * dx bymin = -r - 0.5 * dy bymax = +r + 0.5 * dy for i in range(nx): pxmin = xmin + i * dx # lower end of pixel pxcen = pxmin + dx * 0.5 pxmax = pxmin + dx # upper end of pixel if pxmax > bxmin and pxmin < bxmax: for j in range(ny): pymin = ymin + j * dy pycen = pymin + dy * 0.5 pymax = pymin + dy if pymax > bymin and pymin < bymax: # Distance from circle center to pixel center. d = sqrt(pxcen * pxcen + pycen * pycen) # If pixel center is "well within" circle, count full # pixel. if d < r - pixel_radius: frac[j, i] = 1.0 # If pixel center is "close" to circle border, find # overlap. elif d < r + pixel_radius: # Either do exact calculation or use subpixel # sampling: if use_exact: frac[j, i] = circular_overlap_single_exact( pxmin, pymin, pxmax, pymax, r) / (dx * dy) else: frac[j, i] = circular_overlap_single_subpixel( pxmin, pymin, pxmax, pymax, r, subpixels) # Otherwise, it is fully outside circle. # No action needed. return frac # NOTE: The following two functions use cdef because they are not # intended to be called from the Python code. Using def makes them # callable from outside, but also slower. In any case, these aren't useful # to call from outside because they only operate on a single pixel. cdef double circular_overlap_single_subpixel(double x0, double y0, double x1, double y1, double r, int subpixels): """Return the fraction of overlap between a circle and a single pixel with given extent, using a sub-pixel sampling method.""" cdef unsigned int i, j cdef double x, y, dx, dy, r_squared cdef double frac = 0.0 # Accumulator. dx = (x1 - x0) / subpixels dy = (y1 - y0) / subpixels r_squared = r ** 2 x = x0 - 0.5 * dx for i in range(subpixels): x += dx y = y0 - 0.5 * dy for j in range(subpixels): y += dy if x * x + y * y < r_squared: frac += 1.0 return frac / (subpixels * subpixels) cdef double circular_overlap_single_exact(double xmin, double ymin, double xmax, double ymax, double r): """ Area of overlap of a rectangle and a circle """ if 0.0 <= xmin: if 0.0 <= ymin: return circular_overlap_core(xmin, ymin, xmax, ymax, r) elif 0.0 >= ymax: return circular_overlap_core(-ymax, xmin, -ymin, xmax, r) else: return circular_overlap_single_exact(xmin, ymin, xmax, 0.0, r) \ + circular_overlap_single_exact(xmin, 0.0, xmax, ymax, r) elif 0.0 >= xmax: if 0.0 <= ymin: return circular_overlap_core(-xmax, ymin, -xmin, ymax, r) elif 0.0 >= ymax: return circular_overlap_core(-xmax, -ymax, -xmin, -ymin, r) else: return circular_overlap_single_exact(xmin, ymin, xmax, 0.0, r) \ + circular_overlap_single_exact(xmin, 0.0, xmax, ymax, r) else: if 0.0 <= ymin: return circular_overlap_single_exact(xmin, ymin, 0.0, ymax, r) \ + circular_overlap_single_exact(0.0, ymin, xmax, ymax, r) if 0.0 >= ymax: return circular_overlap_single_exact(xmin, ymin, 0.0, ymax, r) \ + circular_overlap_single_exact(0.0, ymin, xmax, ymax, r) else: return circular_overlap_single_exact(xmin, ymin, 0.0, 0.0, r) \ + circular_overlap_single_exact(0.0, ymin, xmax, 0.0, r) \ + circular_overlap_single_exact(xmin, 0.0, 0.0, ymax, r) \ + circular_overlap_single_exact(0.0, 0.0, xmax, ymax, r) cdef double circular_overlap_core(double xmin, double ymin, double xmax, double ymax, double r): """ Assumes that the center of the circle is <= xmin, ymin (can always modify input to conform to this). """ cdef double area, d1, d2, x1, x2, y1, y2 if xmin * xmin + ymin * ymin > r * r: area = 0.0 elif xmax * xmax + ymax * ymax < r * r: area = (xmax - xmin) * (ymax - ymin) else: area = 0.0 d1 = floor_sqrt(xmax * xmax + ymin * ymin) d2 = floor_sqrt(xmin * xmin + ymax * ymax) if d1 < r and d2 < r: x1, y1 = floor_sqrt(r * r - ymax * ymax), ymax x2, y2 = xmax, floor_sqrt(r * r - xmax * xmax) area = ((xmax - xmin) * (ymax - ymin) - area_triangle(x1, y1, x2, y2, xmax, ymax) + area_arc(x1, y1, x2, y2, r)) elif d1 < r: x1, y1 = xmin, floor_sqrt(r * r - xmin * xmin) x2, y2 = xmax, floor_sqrt(r * r - xmax * xmax) area = (area_arc(x1, y1, x2, y2, r) + area_triangle(x1, y1, x1, ymin, xmax, ymin) + area_triangle(x1, y1, x2, ymin, x2, y2)) elif d2 < r: x1, y1 = floor_sqrt(r * r - ymin * ymin), ymin x2, y2 = floor_sqrt(r * r - ymax * ymax), ymax area = (area_arc(x1, y1, x2, y2, r) + area_triangle(x1, y1, xmin, y1, xmin, ymax) + area_triangle(x1, y1, xmin, y2, x2, y2)) else: x1, y1 = floor_sqrt(r * r - ymin * ymin), ymin x2, y2 = xmin, floor_sqrt(r * r - xmin * xmin) area = (area_arc(x1, y1, x2, y2, r) + area_triangle(x1, y1, x2, y2, xmin, ymin)) return area ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/geometry/core.pxd0000644000175100001660000000133514755160622020516 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst #cython: language_level=3 # This file is needed in order to be able to cimport functions into other Cython files cdef double distance(double x1, double y1, double x2, double y2) cdef double area_arc(double x1, double y1, double x2, double y2, double R) cdef double area_triangle(double x1, double y1, double x2, double y2, double x3, double y3) cdef double area_arc_unit(double x1, double y1, double x2, double y2) cdef int in_triangle(double x, double y, double x1, double y1, double x2, double y2, double x3, double y3) cdef double overlap_area_triangle_unit_circle(double x1, double y1, double x2, double y2, double x3, double y3) cdef double floor_sqrt(double x) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/geometry/core.pyx0000644000175100001660000002751414755160622020552 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3 """ The functions here are the core geometry functions. """ import numpy as np cimport numpy as np cdef extern from "math.h": double asin(double x) double sin(double x) double cos(double x) double sqrt(double x) double fabs(double x) from cpython cimport bool DTYPE = np.float64 ctypedef np.float64_t DTYPE_t cimport cython ctypedef struct point: double x double y ctypedef struct intersections: point p1 point p2 cdef double floor_sqrt(double x): """ In some of the geometrical functions, we have to take the sqrt of a number and we know that the number should be >= 0. However, in some cases the value is e.g. -1e-10, but we want to treat it as zero, which is what this function does. Note that this does **not** check whether negative values are close or not to zero, so this should be used only in cases where the value is expected to be positive on paper. """ if x > 0: return sqrt(x) else: return 0 # NOTE: The following two functions use cdef because they are not intended to be # called from the Python code. Using def makes them callable from outside, but # also slower. Some functions currently return multiple values, and for those we # still use 'def' for now. cdef double distance(double x1, double y1, double x2, double y2): """ Distance between two points in two dimensions. Parameters ---------- x1, y1 : float The coordinates of the first point x2, y2 : float The coordinates of the second point Returns ------- d : float The Euclidean distance between the two points """ return sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) cdef double area_arc(double x1, double y1, double x2, double y2, double r): """ Area of a circle arc with radius r between points (x1, y1) and (x2, y2). References ---------- http://mathworld.wolfram.com/CircularSegment.html """ cdef double a, theta a = distance(x1, y1, x2, y2) theta = 2.0 * asin(0.5 * a / r) return 0.5 * r * r * (theta - sin(theta)) cdef double area_triangle(double x1, double y1, double x2, double y2, double x3, double y3): """ Area of a triangle defined by three vertices. """ return 0.5 * abs(x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) cdef double area_arc_unit(double x1, double y1, double x2, double y2): """ Area of a circle arc with radius R between points (x1, y1) and (x2, y2) References ---------- http://mathworld.wolfram.com/CircularSegment.html """ cdef double a, theta a = distance(x1, y1, x2, y2) theta = 2.0 * asin(0.5 * a) return 0.5 * (theta - sin(theta)) cdef int in_triangle(double x, double y, double x1, double y1, double x2, double y2, double x3, double y3): """ Check if a point (x,y) is inside a triangle """ cdef int c = 0 c += ((y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1) c += ((y2 > y) != (y3 > y) and x < (x3 - x2) * (y - y2) / (y3 - y2) + x2) c += ((y3 > y) != (y1 > y) and x < (x1 - x3) * (y - y3) / (y1 - y3) + x3) return c % 2 == 1 cdef intersections circle_line(double x1, double y1, double x2, double y2): """Intersection of a line defined by two points with a unit circle""" cdef double a, b, delta, dx, dy cdef double tolerance = 1.e-10 cdef intersections inter dx = x2 - x1 dy = y2 - y1 if fabs(dx) < tolerance and fabs(dy) < tolerance: inter.p1.x = 2. inter.p1.y = 2. inter.p2.x = 2. inter.p2.y = 2. elif fabs(dx) > fabs(dy): # Find the slope and intercept of the line a = dy / dx b = y1 - a * x1 # Find the determinant of the quadratic equation delta = 1. + a * a - b * b if delta > 0.: # solutions exist delta = sqrt(delta) inter.p1.x = (- a * b - delta) / (1. + a * a) inter.p1.y = a * inter.p1.x + b inter.p2.x = (- a * b + delta) / (1. + a * a) inter.p2.y = a * inter.p2.x + b else: # no solution, return values > 1 inter.p1.x = 2. inter.p1.y = 2. inter.p2.x = 2. inter.p2.y = 2. else: # Find the slope and intercept of the line a = dx / dy b = x1 - a * y1 # Find the determinant of the quadratic equation delta = 1. + a * a - b * b if delta > 0.: # solutions exist delta = sqrt(delta) inter.p1.y = (- a * b - delta) / (1. + a * a) inter.p1.x = a * inter.p1.y + b inter.p2.y = (- a * b + delta) / (1. + a * a) inter.p2.x = a * inter.p2.y + b else: # no solution, return values > 1 inter.p1.x = 2. inter.p1.y = 2. inter.p2.x = 2. inter.p2.y = 2. return inter cdef point circle_segment_single2(double x1, double y1, double x2, double y2): """ The intersection of a line with the unit circle. The intersection the closest to (x2, y2) is chosen. """ cdef double dx1, dy1, dx2, dy2 cdef intersections inter cdef point pt1, pt2, pt inter = circle_line(x1, y1, x2, y2) pt1 = inter.p1 pt2 = inter.p2 # Can be optimized, but just checking for correctness right now dx1 = fabs(pt1.x - x2) dy1 = fabs(pt1.y - y2) dx2 = fabs(pt2.x - x2) dy2 = fabs(pt2.y - y2) if dx1 > dy1: # compare based on x-axis if dx1 > dx2: pt = pt2 else: pt = pt1 else: if dy1 > dy2: pt = pt2 else: pt = pt1 return pt cdef intersections circle_segment(double x1, double y1, double x2, double y2): """ Intersection(s) of a segment with the unit circle. Discard any solution not on the segment. """ cdef intersections inter, inter_new cdef point pt1, pt2 inter = circle_line(x1, y1, x2, y2) pt1 = inter.p1 pt2 = inter.p2 if (pt1.x > x1 and pt1.x > x2) or (pt1.x < x1 and pt1.x < x2) or (pt1.y > y1 and pt1.y > y2) or (pt1.y < y1 and pt1.y < y2): pt1.x, pt1.y = 2., 2. if (pt2.x > x1 and pt2.x > x2) or (pt2.x < x1 and pt2.x < x2) or (pt2.y > y1 and pt2.y > y2) or (pt2.y < y1 and pt2.y < y2): pt2.x, pt2.y = 2., 2. if pt1.x > 1. and pt2.x < 2.: inter_new.p1 = pt1 inter_new.p2 = pt2 else: inter_new.p1 = pt2 inter_new.p2 = pt1 return inter_new cdef double overlap_area_triangle_unit_circle(double x1, double y1, double x2, double y2, double x3, double y3): """ Given a triangle defined by three points (x1, y1), (x2, y2), and (x3, y3), find the area of overlap with the unit circle. """ cdef double d1, d2, d3 cdef bool in1, in2, in3 cdef bool on1, on2, on3 cdef double area cdef double PI = np.pi cdef intersections inter cdef point pt1, pt2, pt3, pt4, pt5, pt6, pt_tmp # Find distance of all vertices to circle center d1 = x1 * x1 + y1 * y1 d2 = x2 * x2 + y2 * y2 d3 = x3 * x3 + y3 * y3 # Order vertices by distance from origin if d1 < d2: if d2 < d3: pass elif d1 < d3: x2, y2, d2, x3, y3, d3 = x3, y3, d3, x2, y2, d2 else: x1, y1, d1, x2, y2, d2, x3, y3, d3 = x3, y3, d3, x1, y1, d1, x2, y2, d2 else: if d1 < d3: x1, y1, d1, x2, y2, d2 = x2, y2, d2, x1, y1, d1 elif d2 < d3: x1, y1, d1, x2, y2, d2, x3, y3, d3 = x2, y2, d2, x3, y3, d3, x1, y1, d1 else: x1, y1, d1, x2, y2, d2, x3, y3, d3 = x3, y3, d3, x2, y2, d2, x1, y1, d1 if d1 > d2 or d2 > d3 or d1 > d3: raise Exception("ERROR: vertices did not sort correctly") # Determine number of vertices inside circle in1 = d1 < 1 in2 = d2 < 1 in3 = d3 < 1 # Determine which vertices are on the circle on1 = fabs(d1 - 1) < 1.e-10 on2 = fabs(d2 - 1) < 1.e-10 on3 = fabs(d3 - 1) < 1.e-10 if on3 or in3: # triangle is completely in circle area = area_triangle(x1, y1, x2, y2, x3, y3) elif in2 or on2: # If vertex 1 or 2 are on the edge of the circle, then we use the dot # product to vertex 3 to determine whether an intersection takes place. intersect13 = not on1 or x1 * (x3 - x1) + y1 * (y3 - y1) < 0. intersect23 = not on2 or x2 * (x3 - x2) + y2 * (y3 - y2) < 0. if intersect13 and intersect23 and not on2: pt1 = circle_segment_single2(x1, y1, x3, y3) pt2 = circle_segment_single2(x2, y2, x3, y3) area = area_triangle(x1, y1, x2, y2, pt1.x, pt1.y) \ + area_triangle(x2, y2, pt1.x, pt1.y, pt2.x, pt2.y) \ + area_arc_unit(pt1.x, pt1.y, pt2.x, pt2.y) elif intersect13: pt1 = circle_segment_single2(x1, y1, x3, y3) area = area_triangle(x1, y1, x2, y2, pt1.x, pt1.y) \ + area_arc_unit(x2, y2, pt1.x, pt1.y) elif intersect23: pt2 = circle_segment_single2(x2, y2, x3, y3) area = area_triangle(x1, y1, x2, y2, pt2.x, pt2.y) \ + area_arc_unit(x1, y1, pt2.x, pt2.y) else: area = area_arc_unit(x1, y1, x2, y2) elif on1: # The triangle is outside the circle area = 0.0 elif in1: # Check for intersections of far side with circle inter = circle_segment(x2, y2, x3, y3) pt1 = inter.p1 pt2 = inter.p2 pt3 = circle_segment_single2(x1, y1, x2, y2) pt4 = circle_segment_single2(x1, y1, x3, y3) if pt1.x > 1.: # indicates no intersection # Code taken from `sep.h`. # TODO: use `sep` and get rid of this Cython code. if (((0.-pt3.y) * (pt4.x-pt3.x) > (pt4.y-pt3.y) * (0.-pt3.x)) != ((y1-pt3.y) * (pt4.x-pt3.x) > (pt4.y-pt3.y) * (x1-pt3.x))): area = area_triangle(x1, y1, pt3.x, pt3.y, pt4.x, pt4.y) \ + (PI - area_arc_unit(pt3.x, pt3.y, pt4.x, pt4.y)) else: area = area_triangle(x1, y1, pt3.x, pt3.y, pt4.x, pt4.y) \ + area_arc_unit(pt3.x, pt3.y, pt4.x, pt4.y) else: if (pt2.x - x2)**2 + (pt2.y - y2)**2 < (pt1.x - x2)**2 + (pt1.y - y2)**2: pt1, pt2 = pt2, pt1 area = area_triangle(x1, y1, pt3.x, pt3.y, pt1.x, pt1.y) \ + area_triangle(x1, y1, pt1.x, pt1.y, pt2.x, pt2.y) \ + area_triangle(x1, y1, pt2.x, pt2.y, pt4.x, pt4.y) \ + area_arc_unit(pt1.x, pt1.y, pt3.x, pt3.y) \ + area_arc_unit(pt2.x, pt2.y, pt4.x, pt4.y) else: inter = circle_segment(x1, y1, x2, y2) pt1 = inter.p1 pt2 = inter.p2 inter = circle_segment(x2, y2, x3, y3) pt3 = inter.p1 pt4 = inter.p2 inter = circle_segment(x3, y3, x1, y1) pt5 = inter.p1 pt6 = inter.p2 if pt1.x <= 1.: xp, yp = 0.5 * (pt1.x + pt2.x), 0.5 * (pt1.y + pt2.y) area = overlap_area_triangle_unit_circle(x1, y1, x3, y3, xp, yp) \ + overlap_area_triangle_unit_circle(x2, y2, x3, y3, xp, yp) elif pt3.x <= 1.: xp, yp = 0.5 * (pt3.x + pt4.x), 0.5 * (pt3.y + pt4.y) area = overlap_area_triangle_unit_circle(x3, y3, x1, y1, xp, yp) \ + overlap_area_triangle_unit_circle(x2, y2, x1, y1, xp, yp) elif pt5.x <= 1.: xp, yp = 0.5 * (pt5.x + pt6.x), 0.5 * (pt5.y + pt6.y) area = overlap_area_triangle_unit_circle(x1, y1, x2, y2, xp, yp) \ + overlap_area_triangle_unit_circle(x3, y3, x2, y2, xp, yp) else: # no intersections if in_triangle(0., 0., x1, y1, x2, y2, x3, y3): return PI else: return 0. return area ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/geometry/elliptical_overlap.pyx0000644000175100001660000001525414755160622023472 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3 """ The functions defined here allow one to determine the exact area of overlap of an ellipse and a triangle (written by Thomas Robitaille). The approach is to divide the rectangle into two triangles, and reproject these so that the ellipse is a unit circle, then compute the intersection of a triangle with a unit circle. """ import numpy as np cimport numpy as np __all__ = ['elliptical_overlap_grid'] cdef extern from "math.h": double asin(double x) double sin(double x) double cos(double x) double sqrt(double x) from cpython cimport bool DTYPE = np.float64 ctypedef np.float64_t DTYPE_t cimport cython # NOTE: Here we need to make sure we use cimport to import the C functions from # core (since these were defined with cdef). This also requires the core.pxd # file to exist with the function signatures. from .core cimport area_triangle, distance, overlap_area_triangle_unit_circle def elliptical_overlap_grid(double xmin, double xmax, double ymin, double ymax, int nx, int ny, double rx, double ry, double theta, int use_exact, int subpixels): """ elliptical_overlap_grid(xmin, xmax, ymin, ymax, nx, ny, rx, ry, use_exact, subpixels) Area of overlap between an ellipse and a pixel grid. The ellipse is centered on the origin. Parameters ---------- xmin, xmax, ymin, ymax : float Extent of the grid in the x and y direction. nx, ny : int Grid dimensions. rx : float The semimajor axis of the ellipse. ry : float The semiminor axis of the ellipse. theta : float The position angle of the semimajor axis in radians (counterclockwise). use_exact : 0 or 1 If set to 1, calculates the exact overlap, while if set to 0, uses a subpixel sampling method with ``subpixel`` subpixels in each direction. subpixels : int If ``use_exact`` is 0, each pixel is resampled by this factor in each dimension. Thus, each pixel is divided into ``subpixels ** 2`` subpixels. Returns ------- frac : `~numpy.ndarray` 2-d array giving the fraction of the overlap. """ cdef unsigned int i, j cdef double x, y, dx, dy cdef double bxmin, bxmax, bymin, bymax cdef double pxmin, pxmax, pymin, pymax cdef double norm # Define output array cdef np.ndarray[DTYPE_t, ndim=2] frac = np.zeros([ny, nx], dtype=DTYPE) # Find the width of each element in x and y dx = (xmax - xmin) / nx dy = (ymax - ymin) / ny norm = 1.0 / (dx * dy) # For now we use a bounding circle and then use that to find a bounding box # but of course this is inefficient and could be done better. # Find bounding circle radius r = max(rx, ry) # Define bounding box bxmin = -r - 0.5 * dx bxmax = +r + 0.5 * dx bymin = -r - 0.5 * dy bymax = +r + 0.5 * dy for i in range(nx): pxmin = xmin + i * dx # lower end of pixel pxmax = pxmin + dx # upper end of pixel if pxmax > bxmin and pxmin < bxmax: for j in range(ny): pymin = ymin + j * dy pymax = pymin + dy if pymax > bymin and pymin < bymax: if use_exact: frac[j, i] = elliptical_overlap_single_exact( pxmin, pymin, pxmax, pymax, rx, ry, theta) * norm else: frac[j, i] = elliptical_overlap_single_subpixel( pxmin, pymin, pxmax, pymax, rx, ry, theta, subpixels) return frac # NOTE: The following two functions use cdef because they are not # intended to be called from the Python code. Using def makes them # callable from outside, but also slower. In any case, these aren't useful # to call from outside because they only operate on a single pixel. cdef double elliptical_overlap_single_subpixel(double x0, double y0, double x1, double y1, double rx, double ry, double theta, int subpixels): """ Return the fraction of overlap between a ellipse and a single pixel with given extent, using a sub-pixel sampling method. """ cdef unsigned int i, j cdef double x, y cdef double frac = 0.0 # Accumulator. cdef double inv_rx_sq, inv_ry_sq cdef double cos_theta = cos(theta) cdef double sin_theta = sin(theta) cdef double dx, dy cdef double x_tr, y_tr dx = (x1 - x0) / subpixels dy = (y1 - y0) / subpixels inv_rx_sq = 1.0 / (rx * rx) inv_ry_sq = 1.0 / (ry * ry) x = x0 - 0.5 * dx for i in range(subpixels): x += dx y = y0 - 0.5 * dy for j in range(subpixels): y += dy # Transform into frame of rotated ellipse x_tr = y * sin_theta + x * cos_theta y_tr = y * cos_theta - x * sin_theta if x_tr * x_tr * inv_rx_sq + y_tr * y_tr * inv_ry_sq < 1.: frac += 1.0 return frac / (subpixels * subpixels) cdef double elliptical_overlap_single_exact(double xmin, double ymin, double xmax, double ymax, double rx, double ry, double theta): """ Given a rectangle defined by (xmin, ymin, xmax, ymax) and an ellipse with major and minor axes rx and ry respectively, position angle theta, and centered at the origin, find the area of overlap. """ cdef double cos_m_theta = cos(-theta) cdef double sin_m_theta = sin(-theta) cdef double scale # Find scale by which the areas will be shrunk scale = rx * ry # Reproject rectangle to frame of reference in which ellipse is a # unit circle x1, y1 = ((xmin * cos_m_theta - ymin * sin_m_theta) / rx, (xmin * sin_m_theta + ymin * cos_m_theta) / ry) x2, y2 = ((xmax * cos_m_theta - ymin * sin_m_theta) / rx, (xmax * sin_m_theta + ymin * cos_m_theta) / ry) x3, y3 = ((xmax * cos_m_theta - ymax * sin_m_theta) / rx, (xmax * sin_m_theta + ymax * cos_m_theta) / ry) x4, y4 = ((xmin * cos_m_theta - ymax * sin_m_theta) / rx, (xmin * sin_m_theta + ymax * cos_m_theta) / ry) # Divide resulting quadrilateral into two triangles and find # intersection with unit circle return (overlap_area_triangle_unit_circle(x1, y1, x2, y2, x3, y3) + overlap_area_triangle_unit_circle(x1, y1, x4, y4, x3, y3)) * scale ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/geometry/rectangular_overlap.pyx0000644000175100001660000000776214755160622023664 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst # cython: language_level=3 """ This module provides tools to calculate the area of overlap between a rectangle and a pixel grid. """ import numpy as np cimport numpy as np __all__ = ['rectangular_overlap_grid'] cdef extern from "math.h": double asin(double x) double sin(double x) double cos(double x) double sqrt(double x) double fabs(double x) from cpython cimport bool DTYPE = np.float64 ctypedef np.float64_t DTYPE_t cimport cython def rectangular_overlap_grid(double xmin, double xmax, double ymin, double ymax, int nx, int ny, double width, double height, double theta, int use_exact, int subpixels): """ rectangular_overlap_grid(xmin, xmax, ymin, ymax, nx, ny, width, height, use_exact, subpixels) Area of overlap between a rectangle and a pixel grid. The rectangle is centered on the origin. Parameters ---------- xmin, xmax, ymin, ymax : float Extent of the grid in the x and y direction. nx, ny : int Grid dimensions. width : float The width of the rectangle height : float The height of the rectangle theta : float The position angle of the rectangle in radians (counterclockwise). use_exact : 0 or 1 If set to 1, calculates the exact overlap, while if set to 0, uses a subpixel sampling method with ``subpixel`` subpixels in each direction. subpixels : int If ``use_exact`` is 0, each pixel is resampled by this factor in each dimension. Thus, each pixel is divided into ``subpixels ** 2`` subpixels. Returns ------- frac : `~numpy.ndarray` 2-d array giving the fraction of the overlap. """ cdef unsigned int i, j cdef double x, y, dx, dy cdef double pxmin, pxmax, pymin, pymax # Define output array cdef np.ndarray[DTYPE_t, ndim=2] frac = np.zeros([ny, nx], dtype=DTYPE) if use_exact == 1: raise NotImplementedError("Exact mode has not been implemented for " "rectangular apertures") # Find the width of each element in x and y dx = (xmax - xmin) / nx dy = (ymax - ymin) / ny # TODO: can implement a bounding box here for efficiency (as for the # circular and elliptical aperture photometry) for i in range(nx): pxmin = xmin + i * dx # lower end of pixel pxmax = pxmin + dx # upper end of pixel for j in range(ny): pymin = ymin + j * dy pymax = pymin + dy frac[j, i] = rectangular_overlap_single_subpixel( pxmin, pymin, pxmax, pymax, width, height, theta, subpixels) return frac cdef double rectangular_overlap_single_subpixel(double x0, double y0, double x1, double y1, double width, double height, double theta, int subpixels): """ Return the fraction of overlap between a rectangle and a single pixel with given extent, using a sub-pixel sampling method. """ cdef unsigned int i, j cdef double x, y cdef double frac = 0.0 # Accumulator. cdef double cos_theta = cos(theta) cdef double sin_theta = sin(theta) cdef double half_width, half_height half_width = width / 2.0 half_height = height / 2.0 dx = (x1 - x0) / subpixels dy = (y1 - y0) / subpixels x = x0 - 0.5 * dx for i in range(subpixels): x += dx y = y0 - 0.5 * dy for j in range(subpixels): y += dy # Transform into frame of rotated rectangle x_tr = y * sin_theta + x * cos_theta y_tr = y * cos_theta - x * sin_theta if fabs(x_tr) < half_width and fabs(y_tr) < half_height: frac += 1.0 return frac / (subpixels * subpixels) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.7029266 photutils-2.2.0/photutils/geometry/tests/0000755000175100001660000000000014755160634020214 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/geometry/tests/__init__.py0000644000175100001660000000000014755160622022310 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/geometry/tests/test_circular_overlap_grid.py0000644000175100001660000000160714755160622026167 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the circular_overlap_grid module. """ import pytest from numpy.testing import assert_allclose from photutils.geometry import circular_overlap_grid grid_sizes = [50, 500, 1000] circ_sizes = [0.2, 0.4, 0.8] use_exacts = [0, 1] subsamples = [1, 5, 10] @pytest.mark.parametrize('grid_size', grid_sizes) @pytest.mark.parametrize('circ_size', circ_sizes) @pytest.mark.parametrize('use_exact', use_exacts) @pytest.mark.parametrize('subsample', subsamples) def test_circular_overlap_grid(grid_size, circ_size, use_exact, subsample): """ Test normalization of the overlap grid to make sure that a fully enclosed pixel has a value of 1.0. """ g = circular_overlap_grid(-1.0, 1.0, -1.0, 1.0, grid_size, grid_size, circ_size, use_exact, subsample) assert_allclose(g.max(), 1.0) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/geometry/tests/test_elliptical_overlap_grid.py0000644000175100001660000000217614755160622026507 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the elliptical_overlap_grid module. """ import pytest from numpy.testing import assert_allclose from photutils.geometry import elliptical_overlap_grid grid_sizes = [50, 500, 1000] maj_sizes = [0.2, 0.4, 0.8] min_sizes = [0.2, 0.4, 0.8] angles = [0.0, 0.5, 1.0] use_exacts = [0, 1] subsamples = [1, 5, 10] @pytest.mark.parametrize('grid_size', grid_sizes) @pytest.mark.parametrize('maj_size', maj_sizes) @pytest.mark.parametrize('min_size', min_sizes) @pytest.mark.parametrize('angle', angles) @pytest.mark.parametrize('use_exact', use_exacts) @pytest.mark.parametrize('subsample', subsamples) def test_elliptical_overlap_grid(grid_size, maj_size, min_size, angle, use_exact, subsample): """ Test normalization of the overlap grid to make sure that a fully enclosed pixel has a value of 1.0. """ g = elliptical_overlap_grid(-1.0, 1.0, -1.0, 1.0, grid_size, grid_size, maj_size, min_size, angle, use_exact, subsample) assert_allclose(g.max(), 1.0) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/geometry/tests/test_rectangular_overlap_grid.py0000644000175100001660000000163114755160622026667 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the rectangular_overlap_grid module. """ import pytest from numpy.testing import assert_allclose from photutils.geometry import rectangular_overlap_grid grid_sizes = [50, 500, 1000] rect_sizes = [0.2, 0.4, 0.8] angles = [0.0, 0.5, 1.0] subsamples = [1, 5, 10] @pytest.mark.parametrize('grid_size', grid_sizes) @pytest.mark.parametrize('rect_size', rect_sizes) @pytest.mark.parametrize('angle', angles) @pytest.mark.parametrize('subsample', subsamples) def test_rectangular_overlap_grid(grid_size, rect_size, angle, subsample): """ Test normalization of the overlap grid to make sure that a fully enclosed pixel has a value of 1.0. """ g = rectangular_overlap_grid(-1.0, 1.0, -1.0, 1.0, grid_size, grid_size, rect_size, rect_size, angle, 0, subsample) assert_allclose(g.max(), 1.0) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.7039268 photutils-2.2.0/photutils/isophote/0000755000175100001660000000000014755160634017051 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/__init__.py0000644000175100001660000000076614755160622021170 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools for fitting elliptical isophotes to galaxy images. """ from .ellipse import * # noqa: F401, F403 from .fitter import * # noqa: F401, F403 from .geometry import * # noqa: F401, F403 from .harmonics import * # noqa: F401, F403 from .integrator import * # noqa: F401, F403 from .isophote import * # noqa: F401, F403 from .model import * # noqa: F401, F403 from .sample import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/ellipse.py0000644000175100001660000010230714755160622021060 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides a class to fit elliptical isophotes. """ import warnings import numpy as np from astropy.utils.exceptions import AstropyUserWarning from photutils.isophote.fitter import (DEFAULT_CONVERGENCE, DEFAULT_FFLAG, DEFAULT_MAXGERR, DEFAULT_MAXIT, DEFAULT_MINIT, CentralEllipseFitter, EllipseFitter) from photutils.isophote.geometry import EllipseGeometry from photutils.isophote.integrator import BILINEAR from photutils.isophote.isophote import Isophote, IsophoteList from photutils.isophote.sample import CentralEllipseSample, EllipseSample __all__ = ['Ellipse'] class Ellipse: """ 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 then, either because they lie outside the image boundaries or because they were flagged out from the fit by sigma-clipping. In any case, a minimum number of iterations is always performed. If iterations stop because of reasons 2 or 3 above, then those ellipse parameters that generated the lowest absolute values for harmonic amplitudes will be used. At this point, the image data sample coming from the best fit ellipse is fitted by the following function: .. math:: y = y0 + (An * sin(n * E)) + (Bn * cos(n * E)) with :math:`n = 3` and :math:`n = 4`. The corresponding amplitudes (A3, B3, A4, and B4), divided by the semimajor axis length and local intensity gradient, measure the isophote's deviations from perfect ellipticity (these amplitudes, divided by semimajor axis and gradient, are the actual quantities stored in the output `~photutils.isophote.Isophote` instance). The algorithm then measures the integrated intensity and the number of non-flagged pixels inside the elliptical isophote, and also inside the corresponding circle with same center and radius equal to the semimajor axis length. These parameters, their errors, other associated parameters, and auxiliary information, are stored in the `~photutils.isophote.Isophote` instance. Errors in intensity and local gradient are obtained directly from the rms scatter of intensity data along the fitted ellipse. Ellipse geometry errors are obtained from the errors in the coefficients of the first and second simultaneous harmonic fit. Third and fourth harmonic amplitude errors are obtained in the same way, but only after the first and second harmonics are subtracted from the raw data. For more details, see the error analysis in `Busko (1996; ASPC 101, 139) `_. After fitting the ellipse that corresponds to a given value of the semimajor axis (by the process described above), the axis length is incremented/decremented following a predefined rule. At each step, the starting, first-guess, ellipse parameters are taken from the previously fitted ellipse that has the closest semimajor axis length to the current one. On low surface brightness regions (those having large radii), the small values of the image radial gradient can induce large corrections and meaningless values for the ellipse parameters. The algorithm has the ability to stop increasing semimajor axis based on several criteria, including signal-to-noise ratio. See the `~photutils.isophote.Isophote` documentation for the meaning of the stop code reported after each fit. The fit algorithm provides a k-sigma clipping algorithm for cleaning deviant sample points at each isophote, thus improving convergence stability against any non-elliptical structure such as stars, spiral arms, HII regions, defects, etc. The fit algorithm has no way of finding where, in the input image frame, the galaxy to be measured is located. The center (x, y) coordinates need to be close to the actual center for the fit to work. An "object centerer" function helps to verify that the selected position can be used as starting point. This function scans a 10x10 window centered either on the (x, y) coordinates in the `~photutils.isophote.EllipseGeometry` instance passed to the constructor of the `~photutils.isophote.Ellipse` class, or, if any one of them, or both, are set to `None`, on the input image frame center. In case a successful acquisition takes place, the `~photutils.isophote.EllipseGeometry` instance is modified in place to reflect the solution of the object centerer algorithm. In some cases the object centerer algorithm may fail, even though there is enough signal-to-noise to start a fit (e.g., in objects with very high ellipticity). In those cases the sensitivity of the algorithm can be decreased by decreasing the value of the object centerer threshold parameter. The centerer works by looking to where a quantity akin to a signal-to-noise ratio is maximized within the 10x10 window. The centerer can thus be shut off entirely by setting the threshold to a large value >> 1 (meaning, no location inside the search window will achieve that signal-to-noise ratio). A note of caution: the ellipse fitting algorithm was designed explicitly with an elliptical galaxy brightness distribution in mind. In particular, a well defined negative radial intensity gradient across the region being fitted is paramount for the achievement of stable solutions. Use of the algorithm in other types of images (e.g., planetary nebulae) may lead to inability to converge to any acceptable solution. """ def __init__(self, image, geometry=None, threshold=0.1): self.image = image if geometry is not None: self._geometry = geometry else: _x0 = image.shape[1] / 2 _y0 = image.shape[0] / 2 self._geometry = EllipseGeometry(_x0, _y0, 10.0, eps=0.2, pa=np.pi / 2) self.set_threshold(threshold) def set_threshold(self, threshold): """ Modify the threshold value used by the centerer. Parameters ---------- threshold : float The new threshold value to use. """ self._geometry.centerer_threshold = threshold def fit_image(self, sma0=None, minsma=0.0, maxsma=None, step=0.1, conver=DEFAULT_CONVERGENCE, minit=DEFAULT_MINIT, maxit=DEFAULT_MAXIT, fflag=DEFAULT_FFLAG, maxgerr=DEFAULT_MAXGERR, sclip=3.0, nclip=0, integrmode=BILINEAR, linear=None, maxrit=None, fix_center=False, fix_pa=False, fix_eps=False): # This parameter list is quite large and should in principle be # simplified by redistributing these controls to somewhere else. # We keep this design though because it better mimics the flat # architecture used in the original STSDAS task `ellipse`. """ Fit multiple isophotes to the image array. This method loops over each value of the semimajor axis (sma) length (constructed from the input parameters), fitting a single isophote at each sma. The entire set of isophotes is returned in an `~photutils.isophote.IsophoteList` instance. Note that the fix_XXX parameters act in unison. Meaning, if one of them is set via this call, the others will assume their default (False) values. This effectively overrides any settings that are present in the internal `~photutils.isophote.EllipseGeometry` instance that is carried along as a property of this class. If an instance of `~photutils.isophote.EllipseGeometry` was passed to this class' constructor, that instance will be effectively overridden by the fix_XXX parameters in this call. Parameters ---------- sma0 : float, optional The starting value for the semimajor axis length (pixels). This value must not be the minimum or maximum semimajor axis length, but something in between. The algorithm can't start from the very center of the galaxy image because the modelling of elliptical isophotes on that region is poor and it will diverge very easily if not tied to other previously fit isophotes. It can't start from the maximum value either because the maximum is not known beforehand, depending on signal-to-noise. The ``sma0`` value should be selected such that the corresponding isophote has a good signal-to-noise ratio and a clearly defined geometry. If set to `None` (the default), one of two actions will be taken: if a `~photutils.isophote.EllipseGeometry` instance was input to the `~photutils.isophote.Ellipse` constructor, its ``sma`` value will be used. Otherwise, a default value of 10. will be used. minsma : float, optional The minimum value for the semimajor axis length (pixels). The default is 0. maxsma : float or `None`, optional The maximum value for the semimajor axis length (pixels). When set to `None` (default), the algorithm will increase the semimajor axis until one of several conditions will cause it to stop and revert to fit ellipses with sma < ``sma0``. step : float, optional The step value used to grow/shrink the semimajor axis length (pixels if ``linear=True``, or a relative value if ``linear=False``). See the ``linear`` parameter. The default is 0.1. conver : float, optional The main convergence criterion. Iterations stop when the largest harmonic amplitude becomes smaller (in absolute value) than ``conver`` times the harmonic fit rms. The default is 0.05. minit : int, optional The minimum number of iterations to perform. A minimum of 10 (the default) iterations guarantees that, on average, 2 iterations will be available for fitting each independent parameter (the four harmonic amplitudes and the intensity level). For the first isophote, the minimum number of iterations is 2 * ``minit`` to ensure that, even departing from not-so-good initial values, the algorithm has a better chance to converge to a sensible solution. maxit : int, optional The maximum number of iterations to perform. The default is 50. fflag : float, optional The acceptable fraction of flagged data points in the sample. If the actual fraction of valid data points is smaller than this, the iterations will stop and the current `~photutils.isophote.Isophote` will be returned. Flagged data points are points that either lie outside the image frame, are masked, or were rejected by sigma-clipping. The default is 0.7. maxgerr : float, optional The maximum acceptable relative error in the local radial intensity gradient. This is the main control for preventing ellipses to grow to regions of too low signal-to-noise ratio. It specifies the maximum acceptable relative error in the local radial intensity gradient. `Busko (1996; ASPC 101, 139) `_ showed that the fitting precision relates to that relative error. The usual behavior of the gradient relative error is to increase with semimajor axis, being larger in outer, fainter regions of a galaxy image. In the current implementation, the ``maxgerr`` criterion is triggered only when two consecutive isophotes exceed the value specified by the parameter. This prevents premature stopping caused by contamination such as stars and HII regions. A number of actions may happen when the gradient error exceeds ``maxgerr`` (or becomes non-significant and is set to `None`). If the maximum semimajor axis specified by ``maxsma`` is set to `None`, semimajor axis growth is stopped and the algorithm proceeds inwards to the galaxy center. If ``maxsma`` is set to some finite value, and this value is larger than the current semimajor axis length, the algorithm enters non-iterative mode and proceeds outwards until reaching ``maxsma``. The default is 0.5. sclip : float, optional The sigma-clip sigma value. The default is 3.0. nclip : int, optional The number of sigma-clip iterations. The default is 0, which means sigma-clipping is skipped. integrmode : {'bilinear', 'nearest_neighbor', 'mean', 'median'}, \ optional The area integration mode. The default is 'bilinear'. linear : bool, optional The semimajor axis growing/shrinking mode. If `False` (default), the geometric growing mode is chosen, thus the semimajor axis length is increased by a factor of (1. + ``step``), and the process is repeated until either the semimajor axis value reaches the value of parameter ``maxsma``, or the last fitted ellipse has more than a given fraction of its sampled points flagged out (see ``fflag``). The process then resumes from the first fitted ellipse (at ``sma0``) inwards, in steps of (1./(1. + ``step``)), until the semimajor axis length reaches the value ``minsma``. In case of linear growing, the increment or decrement value is given directly by ``step`` in pixels. If ``maxsma`` is set to `None`, the semimajor axis will grow until a low signal-to-noise criterion is met. See ``maxgerr``. maxrit : float or `None`, optional The maximum value of semimajor axis to perform an actual fit. Whenever the current semimajor axis length is larger than ``maxrit``, the isophotes will be extracted using the current geometry, without being fitted. This non-iterative mode may be useful for sampling regions of very low surface brightness, where the algorithm may become unstable and unable to recover reliable geometry information. Non-iterative mode can also be entered automatically whenever the ellipticity exceeds 1.0 or the ellipse center crosses the image boundaries. If `None` (default), then no maximum value is used. fix_center : bool, optional Keep center of ellipse fixed during fit? The default is False. fix_pa : bool, optional Keep position angle of semi-major axis of ellipse fixed during fit? The default is False. fix_eps : bool, optional Keep ellipticity of ellipse fixed during fit? The default is False. Returns ------- result : `~photutils.isophote.IsophoteList` instance A list-like object of `~photutils.isophote.Isophote` instances, sorted by increasing semimajor axis length. """ # multiple fitted isophotes will be stored here isophote_list = [] # get starting sma from appropriate source: keyword parameter, # internal EllipseGeometry instance, or fixed default value. if not sma0: 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: warnings.warn(': Everything is fixed. Fit not possible.', AstropyUserWarning) return IsophoteList([]) if fix_center or fix_pa or fix_eps: # Note that this overrides the geometry instance for good. self._geometry.fix = np.array([fix_center, fix_center, fix_pa, fix_eps]) # first, go from initial sma outwards until # hitting one of several stopping criteria. noiter = False first_isophote = True while True: # first isophote runs longer minit_a = 2 * minit if first_isophote else minit first_isophote = False isophote = self.fit_isophote(sma, step, conver, minit_a, maxit, fflag, maxgerr, sclip, nclip, integrmode, linear, maxrit, noniterate=noiter, isophote_list=isophote_list) # check for failed fit. if isophote.stop_code < 0 or isophote.stop_code == 1: # in case the fit failed right at the outset, return an # empty list. This is the usual case when the user # provides initial guesses that are too way off to enable # the fitting algorithm to find any meaningful solution. if len(isophote_list) == 1: warnings.warn('No meaningful fit was possible.', AstropyUserWarning) return IsophoteList([]) self._fix_last_isophote(isophote_list, -1) # get last isophote from the actual list, since the last # `isophote` instance in this context may no longer be OK. isophote = isophote_list[-1] # if two consecutive isophotes failed to fit, # shut off iterative mode. Or, bail out and # change to go inwards. if (len(isophote_list) > 2 and ((isophote.stop_code == 5 and isophote_list[-2].stop_code == 5) or isophote.stop_code == 1)): if maxsma and maxsma > isophote.sma: # if a maximum sma value was provided by # user, and the current sma is smaller than # maxsma, keep growing sma in non-iterative # mode until reaching it. noiter = True else: # if no maximum sma, stop growing and change # to go inwards. break # reset variable from the actual list, since the last # `isophote` instance may no longer be OK. isophote = isophote_list[-1] # update sma. If exceeded user-defined # maximum, bail out from this loop. sma = isophote.sample.geometry.update_sma(step) if maxsma and sma >= maxsma: break # reset sma so as to go inwards. first_isophote = isophote_list[0] sma, step = first_isophote.sample.geometry.reset_sma(step) # now, go from initial sma inwards towards center. while True: isophote = self.fit_isophote(sma, step, conver, minit, maxit, fflag, maxgerr, sclip, nclip, integrmode, linear, maxrit, going_inwards=True, isophote_list=isophote_list) # if abnormal condition, fix isophote but keep going. if isophote.stop_code < 0: self._fix_last_isophote(isophote_list, 0) # but if we get an error from the scipy fitter, bail out # immediately. This usually happens at very small radii # when the number of data points is too small. if isophote.stop_code == 3: break # reset variable from the actual list, since the last # `isophote` instance may no longer be OK. isophote = isophote_list[-1] # figure out next sma; if exceeded user-defined # minimum, or too small, bail out from this loop sma = isophote.sample.geometry.update_sma(step) if sma <= max(minsma, 0.5): break # if user asked for minsma=0, extract special isophote there if minsma == 0.0: # isophote is appended to isophote_list _ = self.fit_isophote(0.0, isophote_list=isophote_list) # sort list of isophotes according to sma isophote_list.sort() return IsophoteList(isophote_list) def fit_isophote(self, sma, step=0.1, conver=DEFAULT_CONVERGENCE, minit=DEFAULT_MINIT, maxit=DEFAULT_MAXIT, fflag=DEFAULT_FFLAG, maxgerr=DEFAULT_MAXGERR, sclip=3.0, nclip=0, integrmode=BILINEAR, linear=False, maxrit=None, noniterate=False, going_inwards=False, isophote_list=None): """ Fit a single isophote with a given semimajor axis length. The ``step`` and ``linear`` parameters are not used to actually grow or shrink the current fitting semimajor axis length. They are necessary so the sampling algorithm can know where to start the gradient computation and also how to compute the elliptical sector areas (when area integration mode is selected). Parameters ---------- sma : float The semimajor axis length (pixels). step : float, optional The step value used to grow/shrink the semimajor axis length (pixels if ``linear=True``, or a relative value if ``linear=False``). See the ``linear`` parameter. The default is 0.1. conver : float, optional The main convergence criterion. Iterations stop when the largest harmonic amplitude becomes smaller (in absolute value) than ``conver`` times the harmonic fit rms. The default is 0.05. minit : int, optional The minimum number of iterations to perform. A minimum of 10 (the default) iterations guarantees that, on average, 2 iterations will be available for fitting each independent parameter (the four harmonic amplitudes and the intensity level). For the first isophote, the minimum number of iterations is 2 * ``minit`` to ensure that, even departing from not-so-good initial values, the algorithm has a better chance to converge to a sensible solution. maxit : int, optional The maximum number of iterations to perform. The default is 50. fflag : float, optional The acceptable fraction of flagged data points in the sample. If the actual fraction of valid data points is smaller than this, the iterations will stop and the current `~photutils.isophote.Isophote` will be returned. Flagged data points are points that either lie outside the image frame, are masked, or were rejected by sigma-clipping. The default is 0.7. maxgerr : float, optional The maximum acceptable relative error in the local radial intensity gradient. When fitting a single isophote by itself this parameter doesn't have any effect on the outcome. sclip : float, optional The sigma-clip sigma value. The default is 3.0. nclip : int, optional The number of sigma-clip iterations. The default is 0, which means sigma-clipping is skipped. integrmode : {'bilinear', 'nearest_neighbor', 'mean', 'median'}, \ optional The area integration mode. The default is 'bilinear'. linear : bool, optional The semimajor axis growing/shrinking mode. When fitting just one isophote, this parameter is used only by the code that define the details of how elliptical arc segments ("sectors") are extracted from the image when using area extraction modes (see the ``integrmode`` parameter). maxrit : float or `None`, optional The maximum value of semimajor axis to perform an actual fit. Whenever the current semimajor axis length is larger than ``maxrit``, the isophotes will be extracted using the current geometry, without being fitted. This non-iterative mode may be useful for sampling regions of very low surface brightness, where the algorithm may become unstable and unable to recover reliable geometry information. Non-iterative mode can also be entered automatically whenever the ellipticity exceeds 1.0 or the ellipse center crosses the image boundaries. If `None` (default), then no maximum value is used. noniterate : bool, optional Whether the fitting algorithm should be bypassed and an isophote should be extracted with the geometry taken directly from the most recent `~photutils.isophote.Isophote` instance stored in the ``isophote_list`` parameter. This parameter is mainly used when running the method in a loop over different values of semimajor axis length, and we want to change from iterative to non-iterative mode somewhere along the sequence of isophotes. When set to `True`, this parameter overrides the behavior associated with parameter ``maxrit``. The default is `False`. going_inwards : bool, optional Parameter to define the sense of SMA growth. When fitting just one isophote, this parameter is used only by the code that defines the details of how elliptical arc segments ("sectors") are extracted from the image, when using area extraction modes (see the ``integrmode`` parameter). The default is `False`. isophote_list : list or `None`, optional If not `None` (the default), the fitted `~photutils.isophote.Isophote` instance is appended to this list. It must be created and managed by the caller. Returns ------- result : `~photutils.isophote.Isophote` instance The fitted isophote. The fitted isophote is also appended to the input list input to the ``isophote_list`` parameter. """ geometry = self._geometry # if available, geometry from last fitted isophote will be # used as initial guess for next isophote. if isophote_list: geometry = isophote_list[-1].sample.geometry # do the fit if noniterate or (maxrit and sma > maxrit): isophote = self._non_iterative(sma, step, linear, geometry, sclip, nclip, integrmode) else: isophote = self._iterative(sma, step, linear, geometry, sclip, nclip, integrmode, conver, minit, maxit, fflag, maxgerr, going_inwards) # store result in list if isophote_list is not None and isophote.valid: isophote_list.append(isophote) return isophote def _iterative(self, sma, step, linear, geometry, sclip, nclip, integrmode, conver, minit, maxit, fflag, maxgerr, going_inwards=False): if sma > 0.0: # iterative fitter sample = EllipseSample(self.image, sma, astep=step, sclip=sclip, nclip=nclip, linear_growth=linear, geometry=geometry, integrmode=integrmode) fitter = EllipseFitter(sample) else: # sma == 0 requires special handling sample = CentralEllipseSample(self.image, 0.0, geometry=geometry) fitter = CentralEllipseFitter(sample) 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, nclip, integrmode): sample = EllipseSample(self.image, sma, astep=step, sclip=sclip, nclip=nclip, linear_growth=linear, geometry=geometry, integrmode=integrmode) sample.update(geometry.fix) # build isophote without iterating with an EllipseFitter return Isophote(sample, 0, valid=True, stop_code=4) @staticmethod def _fix_last_isophote(isophote_list, index): if isophote_list: isophote = isophote_list.pop() # check if isophote is bad; if so, fix its geometry # to be like the geometry of the index-th isophote # in list. isophote.fix_geometry(isophote_list[index]) # force new extraction of raw data, since # geometry changed. isophote.sample.values = None isophote.sample.update(isophote.sample.geometry.fix) # we take the opportunity to change an eventual # negative stop code to its' positive equivalent. code = 5 if isophote.stop_code < 0 else isophote.stop_code # build new instance so it can have its attributes # populated from the updated sample attributes. new_isophote = Isophote(isophote.sample, isophote.niter, isophote.valid, code) # add new isophote to list isophote_list.append(new_isophote) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/fitter.py0000644000175100001660000004041514755160622020721 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides a class to fit ellipses. """ import math import numpy as np from astropy import log from photutils.isophote.harmonics import (first_and_second_harmonic_function, fit_first_and_second_harmonics) from photutils.isophote.isophote import CentralPixel, Isophote from photutils.isophote.sample import EllipseSample __all__ = ['EllipseFitter'] __doctest_skip__ = ['EllipseFitter.fit'] PI2 = np.pi / 2 MAX_EPS = 0.95 MIN_EPS = 0.05 DEFAULT_CONVERGENCE = 0.05 DEFAULT_MINIT = 10 DEFAULT_MAXIT = 50 DEFAULT_FFLAG = 0.7 DEFAULT_MAXGERR = 0.5 class EllipseFitter: """ Class to fit ellipses. Parameters ---------- sample : `~photutils.isophote.EllipseSample` instance The sample data to be fitted. """ def __init__(self, sample): self._sample = sample def fit(self, *, conver=DEFAULT_CONVERGENCE, minit=DEFAULT_MINIT, maxit=DEFAULT_MAXIT, fflag=DEFAULT_FFLAG, maxgerr=DEFAULT_MAXGERR, going_inwards=False): """ Fit an elliptical isophote. Parameters ---------- conver : float, optional The main convergence criterion. Iterations stop when the largest harmonic amplitude becomes smaller (in absolute value) than ``conver`` times the harmonic fit rms. The default is 0.05. minit : int, optional The minimum number of iterations to perform. A minimum of 10 (the default) iterations guarantees that, on average, 2 iterations will be available for fitting each independent parameter (the four harmonic amplitudes and the intensity level). For the first isophote, the minimum number of iterations is 2 * ``minit`` to ensure that, even departing from not-so-good initial values, the algorithm has a better chance to converge to a sensible solution. maxit : int, optional The maximum number of iterations to perform. The default is 50. fflag : float, optional The acceptable fraction of flagged data points in the sample. If the actual fraction of valid data points is smaller than this, the iterations will stop and the current `~photutils.isophote.Isophote` will be returned. Flagged data points are points that either lie outside the image frame, are masked, or were rejected by sigma-clipping. The default is 0.7. maxgerr : float, optional The maximum acceptable relative error in the local radial intensity gradient. This is the main control for preventing ellipses to grow to regions of too low signal-to-noise ratio. It specifies the maximum acceptable relative error in the local radial intensity gradient. `Busko (1996; ASPC 101, 139) `_ showed that the fitting precision relates to that relative error. The usual behavior of the gradient relative error is to increase with semimajor axis, being larger in outer, fainter regions of a galaxy image. In the current implementation, the ``maxgerr`` criterion is triggered only when two consecutive isophotes exceed the value specified by the parameter. This prevents premature stopping caused by contamination such as stars and HII regions. A number of actions may happen when the gradient error exceeds ``maxgerr`` (or becomes non-significant and is set to `None`). If the maximum semimajor axis specified by ``maxsma`` is set to `None`, semimajor axis growth is stopped and the algorithm proceeds inwards to the galaxy center. If ``maxsma`` is set to some finite value, and this value is larger than the current semimajor axis length, the algorithm enters non-iterative mode and proceeds outwards until reaching ``maxsma``. The default is 0.5. going_inwards : bool, optional Parameter to define the sense of SMA growth. When fitting just one isophote, this parameter is used only by the code that defines the details of how elliptical arc segments ("sectors") are extracted from the image, when using area extraction modes (see the ``integrmode`` parameter in the `~photutils.isophote.EllipseSample` class). The default is `False`. Returns ------- result : `~photutils.isophote.Isophote` instance The fitted isophote, which also contains fit status information. Examples -------- >>> from photutils.isophote import EllipseSample, EllipseFitter >>> sample = EllipseSample(data, sma=10.0) >>> fitter = EllipseFitter(sample) >>> isophote = fitter.fit() """ sample = self._sample # this flag signals that limiting gradient error (`maxgerr`) # wasn't exceeded yet. lexceed = False # here we keep track of the sample that caused the minimum harmonic # amplitude(in absolute value). This will eventually be used to # build the resulting Isophote in cases where iterations run to # the maximum allowed (maxit), or the maximum number of flagged # data points (fflag) is reached. minimum_amplitude_value = np.inf minimum_amplitude_sample = None # these must be passed throughout the execution chain. fixed_parameters = self._sample.geometry.fix for i in range(maxit): # Force the sample to compute its gradient and associated values. sample.update(fixed_parameters) # The extract() method returns sampled values as a 2-d numpy # array with the following structure: # values[0] = 1-d array with angles # values[1] = 1-d array with radii # values[2] = 1-d array with intensity values = sample.extract() # We have to check for a zero-length condition here, and # bail out in case it is detected. The scipy fitter won't # raise an exception for zero-length input arrays, but just # prints an "INFO" message. This may result in an infinite # loop. if len(values[2]) < 1: s = str(sample.geometry.sma) log.warning('Too small sample to warrant a fit. SMA is ' + s) sample.geometry.fix = fixed_parameters return Isophote(sample, i + 1, valid=False, stop_code=3) # Fit harmonic coefficients. Failure in fitting is # a fatal error; terminate immediately with sample # marked as invalid. try: coeffs = fit_first_and_second_harmonics(values[0], values[2]) coeffs = coeffs[0] except Exception as e: log.warning(e) sample.geometry.fix = fixed_parameters return Isophote(sample, i + 1, valid=False, stop_code=3) # Mask out coefficients that control fixed ellipse parameters. free_coeffs = np.ma.masked_array(coeffs[1:], mask=fixed_parameters) # Largest non-masked harmonic in absolute value drives the # correction. largest_harmonic_index = np.argmax(np.abs(free_coeffs)) largest_harmonic = free_coeffs[largest_harmonic_index] # see if the amplitude decreased; if yes, keep the # corresponding sample for eventual later use. if abs(largest_harmonic) < minimum_amplitude_value: minimum_amplitude_value = abs(largest_harmonic) minimum_amplitude_sample = sample # check if converged model = first_and_second_harmonic_function(values[0], coeffs) residual = values[2] - model if ((conver * sample.sector_area * np.std(residual)) > np.abs(largest_harmonic)) and (i >= minit - 1): # Got a valid solution and a minimum number of # iterations has run sample.update(fixed_parameters) return Isophote(sample, i + 1, valid=True, stop_code=0) # it may not have converged yet, but the sample contains too # many invalid data points: return. if sample.actual_points < (sample.total_points * fflag): # when too many data points were flagged, return the # best fit sample instead of the current one. minimum_amplitude_sample.update(fixed_parameters) return Isophote(minimum_amplitude_sample, i + 1, valid=True, stop_code=1) # pick appropriate corrector code. corrector = _CORRECTORS[largest_harmonic_index] # generate *NEW* EllipseSample instance with corrected # parameter. Note that this instance is still devoid of # other information besides its geometry. It needs to be # explicitly updated for computations to proceed. We have to # build a new EllipseSample instance every time because of # the lazy extraction process used by EllipseSample code. To # minimize the number of calls to the area integrators, we # pay a (hopefully smaller) price here, by having multiple # calls to the EllipseSample constructor. sample = corrector.correct(sample, largest_harmonic) sample.update(fixed_parameters) # see if any abnormal (or unusual) conditions warrant # the change to non-iterative mode, or go-inwards mode. proceed, lexceed = self._check_conditions( sample, maxgerr, going_inwards, lexceed) if not proceed: sample.update(fixed_parameters) return Isophote(sample, i + 1, 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) 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_error and sample.gradient_relative_error: if not going_inwards and ( sample.gradient_relative_error > maxgerr or sample.gradient >= 0.0): if lexceed: proceed = False else: lexceed = True else: proceed = False # check if ellipse geometry diverged. if abs(sample.geometry.eps > MAX_EPS): proceed = False if (sample.geometry.x0 < 1.0 or sample.geometry.x0 > sample.image.shape[1] or sample.geometry.y0 < 1.0 or sample.geometry.y0 > sample.image.shape[0]): proceed = False # See if eps == 0 (round isophote) was crossed. # If so, fix it but still proceed if sample.geometry.eps < 0.0: sample.geometry.eps = min(-sample.geometry.eps, MAX_EPS) if sample.geometry.pa < PI2: sample.geometry.pa += PI2 else: sample.geometry.pa -= PI2 # If ellipse is an exact circle, computations will diverge. # Make it slightly flat, but still proceed if sample.geometry.eps == 0.0: sample.geometry.eps = MIN_EPS return proceed, lexceed class _ParameterCorrector: def correct(self, sample, harmonic): raise NotImplementedError class _PositionCorrector(_ParameterCorrector): @staticmethod def finalize_correction(dx, dy, sample): new_x0 = sample.geometry.x0 + dx new_y0 = sample.geometry.y0 + dy return EllipseSample(sample.image, sample.geometry.sma, x0=new_x0, y0=new_y0, astep=sample.geometry.astep, sclip=sample.sclip, nclip=sample.nclip, eps=sample.geometry.eps, position_angle=sample.geometry.pa, linear_growth=sample.geometry.linear_growth, integrmode=sample.integrmode) class _PositionCorrector0(_PositionCorrector): def correct(self, sample, harmonic): aux = -harmonic * (1.0 - sample.geometry.eps) / sample.gradient dx = -aux * math.sin(sample.geometry.pa) dy = aux * math.cos(sample.geometry.pa) return self.finalize_correction(dx, dy, sample) class _PositionCorrector1(_PositionCorrector): def correct(self, sample, harmonic): aux = -harmonic / sample.gradient dx = aux * math.cos(sample.geometry.pa) dy = aux * math.sin(sample.geometry.pa) return self.finalize_correction(dx, dy, sample) class _AngleCorrector(_ParameterCorrector): def correct(self, sample, harmonic): eps = sample.geometry.eps sma = sample.geometry.sma gradient = sample.gradient correction = (harmonic * 2.0 * (1.0 - eps) / sma / gradient / ((1.0 - eps)**2 - 1.0)) # '% np.pi' to make angle lie between 0 and np.pi radians new_pa = (sample.geometry.pa + correction) % np.pi return EllipseSample(sample.image, sample.geometry.sma, x0=sample.geometry.x0, y0=sample.geometry.y0, astep=sample.geometry.astep, sclip=sample.sclip, nclip=sample.nclip, eps=sample.geometry.eps, position_angle=new_pa, linear_growth=sample.geometry.linear_growth, integrmode=sample.integrmode) class _EllipticityCorrector(_ParameterCorrector): def correct(self, sample, harmonic): eps = sample.geometry.eps sma = sample.geometry.sma gradient = sample.gradient correction = harmonic * 2.0 * (1.0 - eps) / sma / gradient new_eps = min((sample.geometry.eps - correction), MAX_EPS) return EllipseSample(sample.image, sample.geometry.sma, x0=sample.geometry.x0, y0=sample.geometry.y0, astep=sample.geometry.astep, sclip=sample.sclip, nclip=sample.nclip, eps=new_eps, position_angle=sample.geometry.pa, linear_growth=sample.geometry.linear_growth, integrmode=sample.integrmode) # instances of corrector code live here: _CORRECTORS = [_PositionCorrector0(), _PositionCorrector1(), _AngleCorrector(), _EllipticityCorrector()] class CentralEllipseFitter(EllipseFitter): """ A special Fitter class to handle the case of the central pixel in the galaxy image. """ def fit(self, **kwargs): """ 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) return CentralPixel(self._sample) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/geometry.py0000644000175100001660000004730714755160622021266 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides a container class to store parameters for the geometry of an ellipse. """ import math import numpy as np from astropy import log __all__ = ['EllipseGeometry'] IN_MASK = [ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], ] OUT_MASK = [ [1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1], [1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1], [1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1], [1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1], [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], [1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1], [1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1], [1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1], [1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1], ] def _area(sma, eps, phi, r): """ Compute elliptical sector area. """ aux = r * math.cos(phi) / sma signal = aux / abs(aux) if abs(aux) >= 1.0: aux = signal return abs(sma**2 * (1.0 - eps) / 2.0 * math.acos(aux)) class EllipseGeometry: r""" Container class to store parameters for the geometry of an ellipse. Parameters that describe the relationship of a given ellipse with other associated ellipses are also encapsulated in this container. These associated ellipses may include, e.g., the two (inner and outer) bounding ellipses that are used to build sectors along the elliptical path. These sectors are used as areas for integrating pixel values, when the area integration mode (mean or median) is used. This class also keeps track of where in the ellipse we are when performing an 'extract' operation. This is mostly relevant when using an area integration mode (as opposed to a pixel integration mode) Parameters ---------- x0, y0 : float The center pixel coordinate of the ellipse. sma : float The semimajor axis of the ellipse in pixels. eps : float The ellipticity of the ellipse. The ellipticity is defined as .. math:: \epsilon = 1 - \frac{b}{a} where a and b are the lengths of the semimajor and semimior axes, respectively. pa : float The position angle (in radians) of the semimajor axis in relation to the positive x axis of the image array (rotating towards the positive y axis). Position angles are defined in the range :math:`0 < PA <= \pi`. Avoid using as starting position angle of 0., since the fit algorithm may not work properly. When the ellipses are such that position angles are near either extreme of the range, noise can make the solution jump back and forth between successive isophotes, by amounts close to 180 degrees. astep : float, optional The step value for growing/shrinking the semimajor axis. It can be expressed either in pixels (when ``linear_growth=True``) or as a relative value (when ``linear_growth=False``). The default is 0.1. linear_growth : bool, optional The semimajor axis growing/shrinking mode. The default is `False`. fix_center : bool, optional Keep center of ellipse fixed during fit? The default is False. fix_pa : bool, optional Keep position angle of semi-major axis of ellipse fixed during fit? The default is False. fix_eps : bool, optional Keep ellipticity of ellipse fixed during fit? The default is False. """ def __init__(self, x0, y0, sma, eps, pa, astep=0.1, linear_growth=False, fix_center=False, fix_pa=False, fix_eps=False): self.x0 = x0 self.y0 = y0 self.sma = sma self.eps = eps self.pa = pa self.astep = astep self.linear_growth = linear_growth # Fixed parameters are flagged in here. Note that the # ordering must follow the same ordering used in the # fitter._CORRECTORS list. self.fix = np.array([fix_center, fix_center, fix_pa, fix_eps]) # limits for sector angular width self._phi_min = 0.05 self._phi_max = 0.2 # variables used in the calculation of the sector angular width sma1, sma2 = self.bounding_ellipses() inner_sma = min((sma2 - sma1), 3.0) self._area_factor = (sma2 - sma1) * inner_sma # sma can eventually be zero! if self.sma > 0.0: self.sector_angular_width = max(min((inner_sma / self.sma), self._phi_max), self._phi_min) self.initial_polar_angle = self.sector_angular_width / 2.0 self.initial_polar_radius = self.radius(self.initial_polar_angle) def find_center(self, image, threshold=0.1, verbose=True): """ Find the center of a galaxy. If the algorithm is successful the (x, y) coordinates in this `~photutils.isophote.EllipseGeometry` (i.e., the ``x0`` and ``y0`` attributes) instance will be modified. The isophote fit algorithm requires an initial guess for the galaxy center (x, y) coordinates and these coordinates must be close to the actual galaxy center for the isophote fit to work. This method provides can provide an initial guess for the galaxy center coordinates. See the **Notes** section below for more details. Parameters ---------- image : 2D `~numpy.ndarray` The image array. Masked arrays are not recognized here. This assumes that centering should always be done on valid pixels. threshold : float, optional The centerer threshold. To turn off the centerer, set this to a large value (i.e., >> 1). The default is 0.1. verbose : bool, optional Whether to print object centering information. The default is `True`. Notes ----- The centerer function scans a 10x10 window centered on the (x, y) coordinates in the `~photutils.isophote.EllipseGeometry` instance passed to the constructor of the `~photutils.isophote.Ellipse` class. If any of the `~photutils.isophote.EllipseGeometry` (x, y) coordinates are `None`, the center of the input image frame is used. If the center acquisition is successful, the `~photutils.isophote.EllipseGeometry` instance is modified in place to reflect the solution of the object centerer algorithm. In some cases the object centerer algorithm may fail even though there is enough signal-to-noise to start a fit (e.g., objects with very high ellipticity). In those cases the sensitivity of the algorithm can be decreased by decreasing the value of the object centerer threshold parameter. The centerer works by looking where a quantity akin to a signal-to-noise ratio is maximized within the 10x10 window. The centerer can thus be shut off entirely by setting the threshold to a large value (i.e., >> 1; meaning no location inside the search window will achieve that signal-to-noise ratio). """ self._centerer_mask_half_size = len(IN_MASK) / 2 self.centerer_threshold = threshold # number of pixels in each mask sz = len(IN_MASK) self._centerer_ones_in = np.ma.masked_array(np.ones(shape=(sz, sz)), mask=IN_MASK) self._centerer_ones_out = np.ma.masked_array(np.ones(shape=(sz, sz)), mask=OUT_MASK) self._centerer_in_mask_npix = np.sum(self._centerer_ones_in) self._centerer_out_mask_npix = np.sum(self._centerer_ones_out) # Check if center coordinates point to somewhere inside the frame. # If not, set then to frame center. shape = image.shape _x0 = self.x0 _y0 = self.y0 if (_x0 is None or _x0 < 0 or _x0 >= shape[1] or _y0 is None or _y0 < 0 or _y0 >= shape[0]): _x0 = shape[1] / 2 _y0 = shape[0] / 2 max_fom = 0.0 max_i = 0 max_j = 0 # scan all positions inside window window_half_size = 5 for i in range(int(_x0 - window_half_size), int(_x0 + window_half_size) + 1): for j in range(int(_y0 - window_half_size), int(_y0 + window_half_size) + 1): # ensure that it stays inside image frame i1 = int(max(0, i - self._centerer_mask_half_size)) j1 = int(max(0, j - self._centerer_mask_half_size)) i2 = int(min(shape[1] - 1, i + self._centerer_mask_half_size)) j2 = int(min(shape[0] - 1, j + self._centerer_mask_half_size)) window = image[j1:j2, i1:i2] # averages in inner and outer regions. inner = np.ma.masked_array(window, mask=IN_MASK) outer = np.ma.masked_array(window, mask=OUT_MASK) inner_avg = np.sum(inner) / self._centerer_in_mask_npix outer_avg = np.sum(outer) / self._centerer_out_mask_npix # standard deviation and figure of merit inner_std = np.std(inner) outer_std = np.std(outer) stddev = np.sqrt(inner_std**2 + outer_std**2) fom = (inner_avg - outer_avg) / stddev if fom > max_fom: max_fom = fom max_i = i max_j = j # figure of merit > threshold: update geometry with new coordinates. if max_fom > threshold: self.x0 = float(max_i) self.y0 = float(max_j) if verbose: log.info(f'Found center at x0 = {self.x0:5.1f}, ' f'y0 = {self.y0:5.1f}') 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/harmonics.py0000644000175100001660000001024214755160622021402 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for computing and fitting harmonic functions. """ import numpy as np 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: raise RuntimeError('Error in least squares fit: ' + solution[3]) # return coefficients and covariance matrix return (solution[0], solution[1]) def first_and_second_harmonic_function(phi, c): r""" Compute the harmonic function value used to calculate the corrections for ellipse fitting. This function includes simultaneously both the first and second order harmonics: .. math:: f(phi) = c[0] + c[1]*\sin(phi) + c[2]*\cos(phi) + c[3]*\sin(2*phi) + c[4]*\cos(2*phi) Parameters ---------- phi : float or `~numpy.ndarray` The angle(s) along the elliptical path, going towards the positive y axis, starting coincident with the position angle. That is, the angles are defined from the semimajor axis that lies in the positive x quadrant. c : `~numpy.ndarray` of shape (5,) Array containing the five harmonic coefficients. Returns ------- result : float or `~numpy.ndarray` The function value(s) at the given input angle(s). """ return (c[0] + c[1] * np.sin(phi) + c[2] * np.cos(phi) + c[3] * np.sin(2 * phi) + c[4] * np.cos(2 * phi)) def fit_first_and_second_harmonics(phi, intensities): r""" Fit the first and second harmonic function values to a set of (angle, intensity) pairs. This function is used to compute corrections for ellipse fitting: .. math:: f(phi) = y0 + a1*\sin(phi) + b1*\cos(phi) + a2*\sin(2*phi) + b2*\cos(2*phi) Parameters ---------- phi : float or `~numpy.ndarray` The angle(s) along the elliptical path, going towards the positive y axis, starting coincident with the position angle. That is, the angles are defined from the semimajor axis that lies in the positive x quadrant. intensities : `~numpy.ndarray` The intensities measured along the elliptical path, at the angles defined by the ``phi`` parameter. Returns ------- y0, a1, b1, a2, b2 : float The fitted harmonic coefficient values. """ a1 = b1 = a2 = b2 = 1.0 def optimize_func(x): return first_and_second_harmonic_function( phi, np.array([x[0], x[1], x[2], x[3], x[4]])) - intensities return _least_squares_fit(optimize_func, [np.mean(intensities), a1, b1, a2, b2]) def fit_upper_harmonic(phi, intensities, order): r""" Fit upper harmonic function to a set of (angle, intensity) pairs. With ``order`` set to 3 or 4, the resulting amplitudes, divided by the semimajor axis length and local gradient, measure the deviations from perfect ellipticity. The harmonic function that is fit is: .. math:: y(phi, order) = y0 + An*\sin(order*phi) + Bn*\cos(order*phi) Parameters ---------- phi : float or `~numpy.ndarray` The angle(s) along the elliptical path, going towards the positive y axis, starting coincident with the position angle. That is, the angles are defined from the semimajor axis that lies in the positive x quadrant. intensities : `~numpy.ndarray` The intensities measured along the elliptical path, at the angles defined by the ``phi`` parameter. order : int The order of the harmonic to be fitted. Returns ------- y0, An, Bn : float The fitted harmonic values. """ an = bn = 1.0 def optimize_func(x): return (x[0] + x[1] * np.sin(order * phi) + x[2] * np.cos(order * phi) - intensities) return _least_squares_fit(optimize_func, [np.mean(intensities), an, bn]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/integrator.py0000644000175100001660000002664014755160622021606 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for pixel integration. """ import math import numpy as np __all__ = ['BILINEAR', 'INTEGRATORS', 'MEAN', 'MEDIAN', 'NEAREST_NEIGHBOR'] # integration modes NEAREST_NEIGHBOR = 'nearest_neighbor' BILINEAR = 'bilinear' MEAN = 'mean' MEDIAN = 'median' class _Integrator: """ Base class that supports different kinds of pixel integration methods. Parameters ---------- image : 2D `~numpy.ndarray` The image array. geometry : `~photutils.isophote.EllipseGeometry` instance Object that encapsulates geometry information about current ellipse. angles : list Output list; contains the angle values along the elliptical path. radii : list Output list; contains the radius values along the elliptical path. intensities : list Output list; contains the extracted intensity values along the elliptical path. """ def __init__(self, image, geometry, angles, radii, intensities): self._image = image self._geometry = geometry self._angles = angles self._radii = radii self._intensities = intensities # for bounds checking self._i_range = range(self._image.shape[1] - 1) self._j_range = range(self._image.shape[0] - 1) def integrate(self, radius, phi): """ The three input lists (angles, radii, intensities) are appended with one sample point taken from the image by a chosen integration method. Sub classes should implement the actual integration method. Parameters ---------- radius : float The length of the radius vector in pixels. phi : float The polar angle of radius vector. """ raise NotImplementedError def _reset(self): """ Reset the lists containing results. This method is for internal use and shouldn't be used by external callers. """ self._angles = [] self._radii = [] self._intensities = [] def _store_results(self, phi, radius, sample): self._angles.append(phi) self._radii.append(radius) self._intensities.append(sample) def get_polar_angle_step(self): """ Return the polar angle step used to walk over the elliptical path. The polar angle step is defined by the actual integrator subclass. Returns ------- result : float The polar angle step. """ raise NotImplementedError def get_sector_area(self): """ Return the area of elliptical sectors where the integration takes place. This area is defined and managed by the actual integrator subclass. Depending on the integrator, the area may be a fixed constant, or may change along the elliptical path, so it's up to the caller to use this information in a correct way. Returns ------- result : float The sector area. """ raise NotImplementedError def is_area(self): """ Return the type of the integrator. An area integrator gets it's value from operating over a (generally variable) number of pixels that define a finite area that lays around the elliptical path, at a certain point on the image defined by a polar angle and radius values. A pixel integrator, by contrast, integrates over a fixed and normally small area related to a single pixel on the image. An example is the bilinear integrator, which integrates over a small, fixed, 5-pixel area. This method checks if the integrator is of the first type or not. Returns ------- result : boolean True if this is an area integrator, False otherwise. """ raise NotImplementedError class _NearestNeighborIntegrator(_Integrator): def integrate(self, radius, phi): self._r = radius # Get image coordinates of (radius, phi) pixel i = int(radius * math.cos(phi + self._geometry.pa) + self._geometry.x0) j = int(radius * math.sin(phi + self._geometry.pa) + self._geometry.y0) # ignore data point if outside image boundaries if (i in self._i_range) and (j in self._j_range): sample = self._image[j][i] if sample is not np.ma.masked: self._store_results(phi, radius, sample) def get_polar_angle_step(self): return 1.0 / self._r def get_sector_area(self): return 1.0 def is_area(self): return False class _BiLinearIntegrator(_Integrator): def integrate(self, radius, phi): self._r = radius # Get image coordinates of (radius, phi) pixel x_ = radius * math.cos(phi + self._geometry.pa) + self._geometry.x0 y_ = radius * math.sin(phi + self._geometry.pa) + self._geometry.y0 i = int(x_) j = int(y_) fx = x_ - i fy = y_ - j # ignore data point if outside image boundaries if (i in self._i_range) and (j in self._j_range): # in the future, will need to handle masked pixels here qx = 1.0 - fx qy = 1.0 - fy if (self._image[j][i] is not np.ma.masked and self._image[j + 1][i] is not np.ma.masked and self._image[j][i + 1] is not np.ma.masked and self._image[j + 1][i + 1] is not np.ma.masked): sample = (self._image[j][i] * qx * qy + self._image[j + 1][i] * qx * fy + self._image[j][i + 1] * fx * qy + self._image[j + 1][i + 1] * fy * fx) self._store_results(phi, radius, sample) def get_polar_angle_step(self): return 1.0 / self._r def get_sector_area(self): return 2.0 def is_area(self): return False class _AreaIntegrator(_Integrator): def __init__(self, image, geometry, angles, radii, intensities): super().__init__(image, geometry, angles, radii, intensities) # build auxiliary bilinear integrator to be used when # sector areas contain a too small number of valid pixels. self._bilinear_integrator = INTEGRATORS[BILINEAR](image, geometry, angles, radii, intensities) def integrate(self, radius, phi): self._phi = phi # Get image coordinates of the four vertices of the elliptical sector. vertex_x, vertex_y = self._geometry.initialize_sector_geometry(phi) self._sector_area = self._geometry.sector_area # step in polar angle to be used by caller next time # when updating the current polar angle `phi` to point # to the next sector. self._phistep = self._geometry.sector_angular_width # define rectangular image area that encompasses the elliptical # sector. We have to account for rounding of pixel indices. i1 = int(min(vertex_x)) - 1 j1 = int(min(vertex_y)) - 1 i2 = int(max(vertex_x)) + 1 j2 = int(max(vertex_y)) + 1 # polar angle limits for this sector phi1, phi2 = self._geometry.polar_angle_sector_limits() # ignore data point if the elliptical sector lies # partially, ou totally, outside image boundaries if (i1 in self._i_range) and (j1 in self._j_range) and \ (i2 in self._i_range) and (j2 in self._j_range): # Scan rectangular image area, compute sample value. npix = 0 accumulator = self.initialize_accumulator() for j in range(j1, j2): for i in range(i1, i2): # Check if polar coordinates of each pixel # put it inside elliptical sector. rp, phip = self._geometry.to_polar(i, j) # check if inside angular limits if phip < phi2 and phip >= phi1: # check if radius is inside bounding ellipses sma1, sma2 = self._geometry.bounding_ellipses() aux = ((1.0 - self._geometry.eps) / math.sqrt(((1.0 - self._geometry.eps) * math.cos(phip))**2 + (math.sin(phip))**2)) r1 = sma1 * aux r2 = sma2 * aux if rp < r2 and rp >= r1: # update accumulator with pixel value pix_value = self._image[j][i] if pix_value is not np.ma.masked: accumulator, npix = self.accumulate( pix_value, accumulator) # If 6 or less pixels were sampled, get the bilinear # interpolated value instead. if npix in range(7): # must reset integrator to remove older samples. self._bilinear_integrator._reset() self._bilinear_integrator.integrate(radius, phi) # because it was reset, current value is the only one stored # internally in the bilinear integrator instance. Move it # from the internal integrator to this instance. if len(self._bilinear_integrator._intensities) > 0: sample_value = self._bilinear_integrator._intensities[0] self._store_results(phi, radius, sample_value) elif npix > 6: sample_value = self.compute_sample_value(accumulator) self._store_results(phi, radius, sample_value) def get_polar_angle_step(self): _, phi2 = self._geometry.polar_angle_sector_limits() return self._geometry.sector_angular_width / 2.0 + phi2 - self._phi def get_sector_area(self): return self._sector_area def is_area(self): return True def initialize_accumulator(self): raise NotImplementedError def accumulate(self, pixel_value, accumulator): raise NotImplementedError def compute_sample_value(self, accumulator): raise NotImplementedError class _MeanIntegrator(_AreaIntegrator): def initialize_accumulator(self): accumulator = 0.0 self._npix = 0 return accumulator def accumulate(self, pixel_value, accumulator): accumulator += pixel_value self._npix += 1 return accumulator, self._npix def compute_sample_value(self, accumulator): return accumulator / self._npix class _MedianIntegrator(_AreaIntegrator): def initialize_accumulator(self): accumulator = [] self._npix = 0 return accumulator def accumulate(self, pixel_value, accumulator): accumulator.append(pixel_value) self._npix += 1 return accumulator, self._npix def compute_sample_value(self, accumulator): accumulator.sort() return accumulator[int(self._npix / 2)] # Specific integrator subclasses can be instantiated from here. INTEGRATORS = { NEAREST_NEIGHBOR: _NearestNeighborIntegrator, BILINEAR: _BiLinearIntegrator, MEAN: _MeanIntegrator, MEDIAN: _MedianIntegrator } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/isophote.py0000644000175100001660000007463414755160622021270 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides classes to store the results of isophote fits. """ import astropy.units as u import numpy as np from astropy.table import QTable from photutils.isophote.harmonics import (first_and_second_harmonic_function, fit_first_and_second_harmonics, fit_upper_harmonic) from photutils.utils._misc import _get_meta __all__ = ['Isophote', 'IsophoteList'] class Isophote: """ Container class to store the results of single isophote fit. The extracted data sample at the given isophote (sampled intensities along the elliptical path on the image) is also kept as an attribute of this class. The container concept helps in segregating information directly related to the sample, from information that more closely relates to the fitting process, such as status codes, errors for isophote parameters, and the like. Parameters ---------- sample : `~photutils.isophote.EllipseSample` instance The sample information. niter : int The number of iterations used to fit the isophote. valid : bool The status of the fitting operation. stop_code : int The fitting stop code: * 0: Normal. * 1: Fewer than the pre-specified fraction of the extracted data points are valid. * 2: Exceeded maximum number of iterations. * 3: Singular matrix in harmonic fit, results may not be valid. This also signals an insufficient number of data points to fit. * 4: Small or wrong gradient, or ellipse diverged. Subsequent ellipses at larger or smaller semimajor axis may have the same constant geometric parameters. It's also used when the user turns off the fitting algorithm via the ``maxrit`` fitting parameter (see the `~photutils.isophote.Ellipse` class). * 5: Ellipse diverged; not even the minimum number of iterations could be executed. Subsequent ellipses at larger or smaller semimajor axis may have the same constant geometric parameters. * -1: Internal use. Attributes ---------- rms : float The root-mean-square of intensity values along the elliptical path. int_err : float The error of the mean (rms / sqrt(# data points)). ellip_err : float The ellipticity error. pa_err : float The position angle error (radians). x0_err : float The error associated with the center x coordinate. y0_err : float The error associated with the center y coordinate. pix_stddev : float The estimate of pixel standard deviation (rms * sqrt(average sector integration area)). grad : float The local radial intensity gradient. grad_error : float The measurement error of the local radial intensity gradient. grad_r_error : float The relative error of local radial intensity gradient. tflux_e : float The sum of all pixels inside the ellipse. npix_e : int The total number of valid pixels inside the ellipse. tflux_c : float The sum of all pixels inside a circle with the same ``sma`` as the ellipse. npix_c : int The total number of valid pixels inside a circle with the same ``sma`` as the ellipse. sarea : float The average sector area on the isophote (pixel**2). ndata : int The number of extracted data points. nflag : int The number of discarded data points. Data points can be discarded either because they are physically outside the image frame boundaries, because they were rejected by sigma-clipping, or they are masked. a3, b3, a4, b4 : float The higher order harmonics that measure the deviations from a perfect ellipse. These values are actually the raw harmonic amplitudes divided by the local radial gradient and the semimajor axis length, so they can directly be compared with each other. The ``b4`` parameter is positive for galaxies with disky (kite-like) isophotes and negative for galaxies with boxy isophotes. a3_err, b3_err, a4_err, b4_err : float The errors associated with the ``a3``, ``b3``, ``a4``, and ``b4`` attributes. """ def __init__(self, sample, niter, valid, stop_code): self.sample = sample self.niter = niter self.valid = valid self.stop_code = stop_code if sample.geometry.sma > 0: self.intens = sample.mean self.rms = np.std(sample.values[2]) self.int_err = self.rms / np.sqrt(sample.actual_points) self.pix_stddev = self.rms * np.sqrt(sample.sector_area) self.grad = sample.gradient self.grad_error = sample.gradient_error self.grad_r_error = sample.gradient_relative_error self.sarea = sample.sector_area self.ndata = sample.actual_points self.nflag = sample.total_points - sample.actual_points # flux contained inside ellipse and circle (self.tflux_e, self.tflux_c, self.npix_e, self.npix_c) = self._compute_fluxes() self._compute_errors() # deviations from a perfect ellipse (self.a3, self.b3, self.a3_err, self.b3_err) = self._compute_deviations(sample, 3) (self.a4, self.b4, self.a4_err, self.b4_err) = self._compute_deviations(sample, 4) # This method is useful for sorting lists of instances. Note # that __lt__ is the python3 way of supporting sorting. def __lt__(self, other): try: return self.sma < other.sma except AttributeError as err: raise AttributeError('Comparison object does not have a "sma" ' 'attribute.') from err def __gt__(self, other): try: return self.sma > other.sma except AttributeError as err: raise AttributeError('Comparison object does not have a "sma" ' 'attribute.') from err def __le__(self, other): try: return self.sma <= other.sma except AttributeError as err: raise AttributeError('Comparison object does not have a "sma" ' 'attribute.') from err def __ge__(self, other): try: return self.sma >= other.sma except AttributeError as err: raise AttributeError('Comparison object does not have a "sma" ' 'attribute.') from err def __eq__(self, other): try: return self.sma == other.sma except AttributeError as err: raise AttributeError('Comparison object does not have a "sma" ' 'attribute.') from err def __ne__(self, other): try: return self.sma != other.sma except AttributeError as err: raise AttributeError('Comparison object does not have a "sma" ' 'attribute.') from err def __str__(self): return str(self.to_table()) @property def sma(self): """ The semimajor axis length (pixels). """ return self.sample.geometry.sma @property def eps(self): """ The ellipticity of the ellipse. """ return self.sample.geometry.eps @property def pa(self): """ The position angle (radians) of the ellipse. """ return self.sample.geometry.pa @property def x0(self): """ The center x coordinate (pixel). """ return self.sample.geometry.x0 @property def y0(self): """ The center y coordinate (pixel). """ return self.sample.geometry.y0 def _compute_fluxes(self): """ Compute integrated flux inside ellipse, as well as inside a circle defined with the same semimajor axis. Pixels in a square section enclosing circle are scanned; the distance of each pixel to the isophote center is compared both with the semimajor axis length and with the length of the ellipse radius vector, and integrals are updated if the pixel distance is smaller. """ # Compute limits of square array that encloses circle. sma = self.sample.geometry.sma x0 = self.sample.geometry.x0 y0 = self.sample.geometry.y0 xsize = self.sample.image.shape[1] ysize = self.sample.image.shape[0] imin = max(0, int(x0 - sma - 0.5) - 1) jmin = max(0, int(y0 - sma - 0.5) - 1) imax = min(xsize, int(x0 + sma + 0.5) + 1) jmax = min(ysize, int(y0 + sma + 0.5) + 1) # Integrate if (jmax - jmin > 1) and (imax - imin) > 1: y, x = np.mgrid[jmin:jmax, imin:imax] radius, angle = self.sample.geometry.to_polar(x, y) radius_e = self.sample.geometry.radius(angle) midx = (radius <= sma) values = self.sample.image[y[midx], x[midx]] tflux_c = np.ma.sum(values) npix_c = np.ma.count(values) midx2 = (radius <= radius_e) values = self.sample.image[y[midx2], x[midx2]] tflux_e = np.ma.sum(values) npix_e = np.ma.count(values) else: tflux_e = 0.0 tflux_c = 0.0 npix_e = 0 npix_c = 0 return tflux_e, tflux_c, npix_e, npix_c def _compute_deviations(self, sample, n): """ Compute deviations from a perfect ellipse, based on the amplitudes and errors for harmonic "n". Note that we first subtract the first and second harmonics from the raw data. """ try: # upper (third and fourth) harmonics up_coeffs, up_inv_hessian = fit_upper_harmonic(sample.values[0], sample.values[2], n) a = up_coeffs[1] / self.sma / abs(sample.gradient) b = up_coeffs[2] / self.sma / abs(sample.gradient) def errfunc(x, phi, order, intensities): return (x[0] + x[1] * np.sin(order * phi) + x[2] * np.cos(order * phi) - intensities) up_var_residual = np.std(errfunc(up_coeffs, self.sample.values[0], n, self.sample.values[2]), ddof=len(up_coeffs))**2 up_covariance = up_inv_hessian * up_var_residual ce = np.sqrt(np.diag(up_covariance)) # this comes from the old code. Likely it was based on # empirical experience with the STSDAS task, so we leave # it here without too much thought. gre = self.grad_r_error if self.grad_r_error is not None else 0.8 a_err = abs(a) * np.sqrt((ce[1] / up_coeffs[1])**2 + gre**2) b_err = abs(b) * np.sqrt((ce[2] / up_coeffs[2])**2 + gre**2) except Exception: # we want to catch everything a = b = a_err = b_err = None return a, b, a_err, b_err def _compute_errors(self): """ Compute parameter errors based on the diagonal of the covariance matrix of the four harmonic coefficients for harmonics n=1 and n=2.0. """ try: coeffs, covariance = fit_first_and_second_harmonics( self.sample.values[0], self.sample.values[2]) model = first_and_second_harmonic_function(self.sample.values[0], coeffs) var_residual = np.std(self.sample.values[2] - model, ddof=len(coeffs)) ** 2 errors = np.sqrt(np.diagonal(covariance * var_residual)) eps = self.sample.geometry.eps pa = self.sample.geometry.pa # parameter errors result from direct projection of # coefficient errors. These showed to be the error estimators # that best convey the errors measured in Monte Carlo # experiments (see Busko 1996; ASPC 101, 139). ea = abs(errors[2] / self.grad) eb = abs(errors[1] * (1.0 - eps) / self.grad) self.x0_err = np.sqrt((ea * np.cos(pa))**2 + (eb * np.sin(pa))**2) self.y0_err = np.sqrt((ea * np.sin(pa))**2 + (eb * np.cos(pa))**2) self.ellip_err = (abs(2.0 * errors[4] * (1.0 - eps) / self.sma / self.grad)) if abs(eps) > np.finfo(float).resolution: self.pa_err = (abs(2.0 * errors[3] * (1.0 - eps) / self.sma / self.grad / (1.0 - (1.0 - eps)**2))) else: self.pa_err = 0.0 except Exception: # we want to catch everything self.x0_err = self.y0_err = self.pa_err = self.ellip_err = 0.0 def fix_geometry(self, isophote): """ Fix the geometry of a problematic isophote to be identical to the input isophote. This method should be called when the fitting goes berserk and delivers an isophote with bad geometry, such as ellipticity > 1 or another meaningless situation. This is not a problem in itself when fitting any given isophote, but will create an error when the affected isophote is used as starting guess for the next fit. Parameters ---------- isophote : `~photutils.isophote.Isophote` instance The isophote from which to take the geometry information. """ self.sample.geometry.eps = isophote.sample.geometry.eps self.sample.geometry.pa = isophote.sample.geometry.pa self.sample.geometry.x0 = isophote.sample.geometry.x0 self.sample.geometry.y0 = isophote.sample.geometry.y0 def sampled_coordinates(self): """ Return the (x, y) coordinates where the image was sampled in order to get the intensities associated with this isophote. Returns ------- x, y : 1D `~numpy.ndarray` The x and y coordinates as 1D arrays. """ return self.sample.coordinates() def to_table(self): """ Return the main isophote parameters as an astropy `~astropy.table.QTable`. Returns ------- result : `~astropy.table.QTable` An astropy `~astropy.table.QTable` containing the main isophote parameters. """ return _isophote_list_to_table([self]) class CentralPixel(Isophote): """ Specialized Isophote class for the galaxy central pixel. This class holds only a single intensity value at the central position. Thus, most of its attributes are hardcoded to `None` or a default value when appropriate. Parameters ---------- sample : `~photutils.isophote.EllipseSample` instance The sample information. """ def __init__(self, sample): super().__init__(sample, 0, 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.grad_error = None self.grad_r_error = None self.sarea = None self.ndata = sample.actual_points self.nflag = sample.total_points - sample.actual_points self.tflux_e = self.tflux_c = self.npix_e = self.npix_c = None self.a3 = self.b3 = 0.0 self.a4 = self.b4 = 0.0 self.a3_err = self.b3_err = 0.0 self.a4_err = self.b4_err = 0.0 self.ellip_err = 0.0 self.pa_err = 0.0 self.x0_err = 0.0 self.y0_err = 0.0 def __eq__(self, other): try: return self.sma == other.sma except AttributeError as err: raise AttributeError('Comparison object does not have a "sma" ' 'attribute.') from err @property def eps(self): """ 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 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 grad_error(self): """ The measurement error of the local radial intensity gradient. """ return self._collect_as_array('grad_error') @property def grad_r_error(self): """ The relative error of local radial intensity gradient. """ return self._collect_as_array('grad_r_error') @property def sarea(self): """ The average sector area on the isophote (pixel**2). """ return self._collect_as_array('sarea') @property def ndata(self): """ The number of extracted data points. """ return self._collect_as_array('ndata') @property def nflag(self): """ The number of discarded data points. Data points can be discarded either because they are physically outside the image frame boundaries, because they were rejected by sigma-clipping, or they are masked. """ return self._collect_as_array('nflag') @property def niter(self): """ The number of iterations used to fit the isophote. """ return self._collect_as_array('niter') @property def valid(self): """ The status of the fitting operation. """ return self._collect_as_array('valid') @property def stop_code(self): """ The fitting stop code. """ return self._collect_as_array('stop_code') @property def tflux_e(self): """ The sum of all pixels inside the ellipse. """ return self._collect_as_array('tflux_e') @property def tflux_c(self): """ The sum of all pixels inside a circle with the same ``sma`` as the ellipse. """ return self._collect_as_array('tflux_c') @property def npix_e(self): """ The total number of valid pixels inside the ellipse. """ return self._collect_as_array('npix_e') @property def npix_c(self): """ The total number of valid pixels inside a circle with the same ``sma`` as the ellipse. """ return self._collect_as_array('npix_c') @property def a3(self): """ A third-order harmonic coefficient. See the :func:`~photutils.isophote.fit_upper_harmonic` function for details. """ return self._collect_as_array('a3') @property def b3(self): """ A third-order harmonic coefficient. See the :func:`~photutils.isophote.fit_upper_harmonic` function for details. """ return self._collect_as_array('b3') @property def a4(self): """ A fourth-order harmonic coefficient. See the :func:`~photutils.isophote.fit_upper_harmonic` function for details. """ return self._collect_as_array('a4') @property def b4(self): """ A fourth-order harmonic coefficient. See the :func:`~photutils.isophote.fit_upper_harmonic` function for details. """ return self._collect_as_array('b4') @property def a3_err(self): """ The error associated with `~photutils.isophote.IsophoteList.a3`. """ return self._collect_as_array('a3_err') @property def b3_err(self): """ The error associated with `~photutils.isophote.IsophoteList.b3`. """ return self._collect_as_array('b3_err') @property def a4_err(self): """ The error associated with `~photutils.isophote.IsophoteList.a4`. """ return self._collect_as_array('a4_err') @property def b4_err(self): """ The error associated with `~photutils.isophote.IsophoteList.b3`. """ return self._collect_as_array('b4_err') def to_table(self, columns='main'): """ Convert an `~photutils.isophote.IsophoteList` instance to a `~astropy.table.QTable` with the main isophote parameters. Parameters ---------- columns : list of str A list of properties to export from the isophote list. If ``columns`` is 'all' or 'main', it will pick all or few of the main properties. Returns ------- result : `~astropy.table.QTable` An astropy QTable with the main isophote parameters. """ return _isophote_list_to_table(self, columns) def get_names(self): """ 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 def _isophote_list_to_table(isophote_list, columns='main'): """ Convert an `~photutils.isophote.IsophoteList` instance to a `~astropy.table.QTable`. Parameters ---------- isophote_list : list of `~photutils.isophote.Isophote` or \ `~photutils.isophote.IsophoteList` instance A list of isophotes. columns : list of str A list of properties to export from the ``isophote_list``. If ``columns`` is 'all' or 'main', it will pick all or few of the main properties. Returns ------- result : `~astropy.table.QTable` An astropy QTable with the selected or all isophote parameters. """ properties = {} isotable = QTable() isotable.meta.update(_get_meta()) # keep isotable.meta type # main_properties: `List` # A list of main parameters matching the original names of # the isophote_list parameters def __rename_properties(properties, orig_names=('int_err', 'eps', 'ellip_err', 'grad_r_error', 'nflag'), new_names=('intens_err', 'ellipticity', 'ellipticity_err', 'grad_rerror', 'nflag')): """ Simple renaming for some of the isophote_list parameters. Parameters ---------- properties : dict A dictionary with the list of the isophote_list parameters. orig_names : list A list of original names in the isophote_list parameters to be renamed. new_names : list A list of new names matching in length of the orig_names. Returns ------- properties : dict A dictionary with the list of the renamed isophote_list parameters. """ main_properties = ['sma', 'intens', 'int_err', 'eps', 'ellip_err', 'pa', 'pa_err', 'grad', 'grad_error', 'grad_r_error', 'x0', 'x0_err', 'y0', 'y0_err', 'ndata', 'nflag', 'niter', 'stop_code'] for an_item in main_properties: if an_item in orig_names: properties[an_item] = new_names[orig_names.index(an_item)] else: properties[an_item] = an_item return properties if columns == 'all': properties = _get_properties(isophote_list) properties = __rename_properties(properties) elif columns == 'main': properties = __rename_properties(properties) else: for an_item in columns: properties[an_item] = an_item for k, v in properties.items(): isotable[v] = np.array([getattr(iso, k) for iso in isophote_list]) if k in ('pa', 'pa_err'): isotable[v] = isotable[v] * 180.0 / np.pi * u.deg return isotable ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/model.py0000644000175100001660000001424514755160622020526 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module profiles tools for building a model elliptical galaxy image from a list of isophotes. """ import numpy as np from scipy.interpolate import LSQUnivariateSpline from photutils.isophote.geometry import EllipseGeometry __all__ = ['build_ellipse_model'] def build_ellipse_model(shape, isolist, fill=0.0, high_harmonics=False): """ Build a model elliptical galaxy image from a list of isophotes. For each ellipse in the input isophote list the algorithm fills the output image array with the corresponding isophotal intensity. Pixels in the output array are in general only partially covered by the isophote "pixel". The algorithm takes care of this partial pixel coverage by keeping track of how much intensity was added to each pixel by storing the partial area information in an auxiliary array. The information in this array is then used to normalize the pixel intensities. Parameters ---------- shape : 2-tuple The (ny, nx) shape of the array used to generate the input ``isolist``. isolist : `~photutils.isophote.IsophoteList` instance The isophote list created by the `~photutils.isophote.Ellipse` class. fill : float, optional The constant value to fill empty pixels. If an output pixel has no contribution from any isophote, it will be assigned this value. The default is 0. high_harmonics : bool, optional Whether to add the higher-order harmonics (i.e., ``a3``, ``b3``, ``a4``, and ``b4``; see `~photutils.isophote.Isophote` for details) to the result. Returns ------- result : 2D `~numpy.ndarray` The image with the model galaxy. """ if len(isolist) == 0: raise ValueError('isolist must not be empty') # the target grid is spaced in 0.1 pixel intervals so as # to ensure no gaps will result on the output array. finely_spaced_sma = np.arange(isolist[0].sma, isolist[-1].sma, 0.1) # interpolate ellipse parameters # End points must be discarded, but how many? # This seems to work so far nodes = isolist.sma[2:-2] intens_array = LSQUnivariateSpline( isolist.sma, isolist.intens, nodes)(finely_spaced_sma) eps_array = LSQUnivariateSpline( isolist.sma, isolist.eps, nodes)(finely_spaced_sma) pa_array = LSQUnivariateSpline( isolist.sma, isolist.pa, nodes)(finely_spaced_sma) x0_array = LSQUnivariateSpline( isolist.sma, isolist.x0, nodes)(finely_spaced_sma) y0_array = LSQUnivariateSpline( isolist.sma, isolist.y0, nodes)(finely_spaced_sma) grad_array = LSQUnivariateSpline( isolist.sma, isolist.grad, nodes)(finely_spaced_sma) a3_array = LSQUnivariateSpline( isolist.sma, isolist.a3, nodes)(finely_spaced_sma) b3_array = LSQUnivariateSpline( isolist.sma, isolist.b3, nodes)(finely_spaced_sma) a4_array = LSQUnivariateSpline( isolist.sma, isolist.a4, nodes)(finely_spaced_sma) b4_array = LSQUnivariateSpline( isolist.sma, isolist.b4, nodes)(finely_spaced_sma) # Return deviations from ellipticity to their original amplitude meaning a3_array = -a3_array * grad_array * finely_spaced_sma b3_array = -b3_array * grad_array * finely_spaced_sma a4_array = -a4_array * grad_array * finely_spaced_sma b4_array = -b4_array * grad_array * finely_spaced_sma # correct deviations cased by fluctuations in spline solution eps_array[np.where(eps_array < 0.0)] = 0.0 result = np.zeros(shape=shape) weight = np.zeros(shape=shape) eps_array[np.where(eps_array < 0.0)] = 0.05 # for each interpolated isophote, generate intensity values on the # output image array # for index in range(len(finely_spaced_sma)): for index in range(1, len(finely_spaced_sma)): sma0 = finely_spaced_sma[index] eps = eps_array[index] pa = pa_array[index] x0 = x0_array[index] y0 = y0_array[index] geometry = EllipseGeometry(x0, y0, sma0, eps, pa) intens = intens_array[index] # scan angles. Need to go a bit beyond full circle to ensure # full coverage. r = sma0 phi = 0.0 while phi <= 2 * np.pi + geometry._phi_min: # we might want to add the third and fourth harmonics # to the basic isophotal intensity. harm = 0.0 if high_harmonics: harm = (a3_array[index] * np.sin(3.0 * phi) + b3_array[index] * np.cos(3.0 * phi) + a4_array[index] * np.sin(4.0 * phi) + b4_array[index] * np.cos(4.0 * phi)) # get image coordinates of (r, phi) pixel x = r * np.cos(phi + pa) + x0 y = r * np.sin(phi + pa) + y0 i = int(x) j = int(y) if i > 0 and i < shape[1] - 1 and j > 0 and j < shape[0] - 1: # get fractional deviations relative to target array fx = x - float(i) fy = y - float(j) # add up the isophote contribution to the overlapping pixels result[j, i] += (intens + harm) * (1.0 - fy) * (1.0 - fx) result[j, i + 1] += (intens + harm) * (1.0 - fy) * fx result[j + 1, i] += (intens + harm) * fy * (1.0 - fx) result[j + 1, i + 1] += (intens + harm) * fy * fx # add up the fractional area contribution to the # overlapping pixels weight[j, i] += (1.0 - fy) * (1.0 - fx) weight[j, i + 1] += (1.0 - fy) * fx weight[j + 1, i] += fy * (1.0 - fx) weight[j + 1, i + 1] += fy * fx # step towards next pixel on ellipse phi = max((phi + 0.75 / r), geometry._phi_min) r = max(geometry.radius(phi), 0.5) # if outside image boundaries, ignore. else: break # zero weight values must be set to 1.0 weight[np.where(weight <= 0.0)] = 1.0 # normalize result /= weight # fill value result[np.where(result == 0.0)] = fill return result ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/sample.py0000644000175100001660000003771114755160622020712 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides a class to sample data along an elliptical path. """ import copy import numpy as np from photutils.isophote.geometry import EllipseGeometry from photutils.isophote.integrator import INTEGRATORS __all__ = ['EllipseSample'] class EllipseSample: """ Class to sample image data along an elliptical path. The image intensities along the elliptical path can be extracted using a selection of integration algorithms. The ``geometry`` attribute describes the geometry of the elliptical path. Parameters ---------- image : 2D `~numpy.ndarray` The input image. sma : float The semimajor axis length in pixels. x0, y0 : float, optional The (x, y) coordinate of the ellipse center. astep : float, optional The step value for growing/shrinking the semimajor axis. It can be expressed either in pixels (when ``linear_growth=True``) or as a relative value (when ``linear_growth=False``). The default is 0.1. eps : float, optional The ellipticity of the ellipse. The default is 0.2. position_angle : float, optional The position angle of ellipse in relation to the positive x axis of the image array (rotating towards the positive y axis). The default is 0. sclip : float, optional The sigma-clip sigma value. The default is 3.0. nclip : int, optional The number of sigma-clip iterations. Set to zero to skip sigma-clipping. The default is 0. linear_growth : bool, optional The semimajor axis growing/shrinking mode. The default is `False`. integrmode : {'bilinear', 'nearest_neighbor', 'mean', 'median'}, optional The area integration mode. The default is 'bilinear'. geometry : `~photutils.isophote.EllipseGeometry` instance or `None` The geometry that describes the ellipse. This can be used in lieu of the explicit specification of parameters ``sma``, ``x0``, ``y0``, ``eps``, etc. In any case, the `~photutils.isophote.EllipseGeometry` instance becomes an attribute of the `~photutils.isophote.EllipseSample` object. The default is `None`. Attributes ---------- values : 2D `~numpy.ndarray` The sampled values as a 2D array, where the rows contain the angles, radii, and extracted intensity values, respectively. mean : float The mean intensity along the elliptical path. geometry : `~photutils.isophote.EllipseGeometry` instance The geometry of the elliptical path. gradient : float The local radial intensity gradient. gradient_error : float The error associated with the local radial intensity gradient. gradient_relative_error : float The relative error associated with the local radial intensity gradient. sector_area : float The average area of the sectors along the elliptical path from which the sample values were integrated. total_points : int The total number of sample values that would cover the entire elliptical path. actual_points : int The actual number of sample values that were taken from the image. It can be smaller than ``total_points`` when the ellipse encompasses regions outside the image, or when sigma-clipping removed some of the points. """ def __init__(self, image, sma, x0=None, y0=None, astep=0.1, eps=0.2, position_angle=0.0, sclip=3.0, nclip=0, linear_growth=False, integrmode='bilinear', geometry=None): self.image = image self.integrmode = integrmode if geometry: # when the geometry is inherited from somewhere else, # its sma attribute must be replaced by the value # explicitly passed to the constructor. self.geometry = copy.deepcopy(geometry) self.geometry.sma = sma else: # if no center was specified, assume it's roughly # coincident with the image center _x0 = x0 _y0 = y0 if not _x0 or not _y0: _x0 = image.shape[1] / 2 _y0 = image.shape[0] / 2 self.geometry = EllipseGeometry(_x0, _y0, sma, eps, position_angle, astep, linear_growth) # sigma-clip parameters self.sclip = sclip self.nclip = nclip # extracted values associated with this sample. self.values = None self.mean = None self.gradient = None self.gradient_error = None self.gradient_relative_error = None self.sector_area = None # total_points reports the total number of pairs angle-radius that # were attempted. actual_points reports the actual number of sampled # pairs angle-radius that resulted in valid values. self.total_points = 0 self.actual_points = 0 def extract(self): """ Extract sample data by scanning an elliptical path over the image array. Returns ------- result : 2D `~numpy.ndarray` The rows of the array contain the angles, radii, and extracted intensity values, respectively. """ # the sample values themselves are kept cached to prevent # multiple calls to the integrator code. if self.values is not None: return self.values s = self._extract() self.values = s return s def _extract(self, phi_min=0.05): # Here the actual sampling takes place. This is called only once # during the life of an EllipseSample instance, because it's an # expensive calculation. This method should not be called from # external code. # To force it to rerun, set "sample.values = None" before # calling sample.extract(). # individual extracted sample points will be stored in here angles = [] radii = [] intensities = [] sector_areas = [] # reset counters self.total_points = 0 self.actual_points = 0 # build integrator integrator = INTEGRATORS[self.integrmode](self.image, self.geometry, angles, radii, intensities) # initialize walk along elliptical path radius = self.geometry.initial_polar_radius phi = self.geometry.initial_polar_angle # In case of an area integrator, ask the integrator to deliver a # hint of how much area the sectors will have. In case of too # small areas, tests showed that the area integrators (mean, # median) won't perform properly. In that case, we override the # caller's selection and use the bilinear integrator regardless. if integrator.is_area(): integrator.integrate(radius, phi) area = integrator.get_sector_area() # this integration that just took place messes up with the # storage arrays and the constructors. We have to build a new # integrator instance from scratch, even if it is the same # kind as originally selected by the caller. angles = [] radii = [] intensities = [] if area < 1.0: integrator = INTEGRATORS['bilinear']( self.image, self.geometry, angles, radii, intensities) else: integrator = INTEGRATORS[self.integrmode](self.image, self.geometry, angles, radii, intensities) # walk along elliptical path, integrating at specified # places defined by polar vector. Need to go a bit beyond # full circle to ensure full coverage. while phi <= np.pi * 2.0 + phi_min: # do the integration at phi-radius position, and append # results to the angles, radii, and intensities lists. integrator.integrate(radius, phi) # store sector area locally sector_areas.append(integrator.get_sector_area()) # update total number of points self.total_points += 1 # update angle and radius to be used to define # next polar vector along the elliptical path phistep_ = integrator.get_polar_angle_step() phi += min(phistep_, 0.5) radius = self.geometry.radius(phi) # average sector area is calculated after the integrator had # the opportunity to step over the entire elliptical path. self.sector_area = np.mean(np.array(sector_areas)) # apply sigma-clipping. angles, radii, intensities = self._sigma_clip(angles, radii, intensities) # actual number of sampled points, after sigma-clip removed outliers. self.actual_points = len(angles) # pack results in 2-d array return np.array([np.array(angles), np.array(radii), np.array(intensities)]) def _sigma_clip(self, angles, radii, intensities): if self.nclip > 0: for _ in range(self.nclip): # do not use list.copy()! must be python2-compliant. angles, radii, intensities = self._iter_sigma_clip( angles[:], radii[:], intensities[:]) return np.array(angles), np.array(radii), np.array(intensities) def _iter_sigma_clip(self, angles, radii, intensities): # Can't use scipy or astropy tools because they use masked arrays. # Also, they operate on a single array, and we need to operate on # three arrays simultaneously. We need something that physically # removes the clipped points from the arrays, since that is what # the remaining of the `ellipse` code expects. r_angles = [] r_radii = [] r_intensities = [] values = np.array(intensities) mean = np.mean(values) sig = np.std(values) lower = mean - self.sclip * sig upper = mean + self.sclip * sig count = 0 for k in range(len(intensities)): if intensities[k] >= lower and intensities[k] < upper: r_angles.append(angles[k]) r_radii.append(radii[k]) r_intensities.append(intensities[k]) count += 1 return r_angles, r_radii, r_intensities def update(self, fixed_parameters=None): """ Update this `~photutils.isophote.EllipseSample` instance. This method calls the :meth:`~photutils.isophote.EllipseSample.extract` method to get the values that match the current ``geometry`` attribute, and then computes the mean intensity, local gradient, and other associated quantities. Parameters ---------- fixed_parameters : `None` or array_like, optional An array of the fixed parameters. Must have 4 elements, corresponding to x center, y center, PA, and EPS. """ if fixed_parameters is None: fixed_parameters = np.array([False, False, False, False]) self.geometry.fix = fixed_parameters step = self.geometry.astep # Update the mean value first, using extraction from main sample. s = self.extract() self.mean = np.mean(s[2]) # Get sample with same geometry but at a different distance from # center. Estimate gradient from there. gradient, gradient_error = self._get_gradient(step) # Check for meaningful gradient. If no meaningful gradient, try # another sample, this time using larger radius. Meaningful # gradient means something shallower, but still close to within # a factor 3 from previous gradient estimate. If no previous # estimate is available, guess it by adding the error to the # current gradient. previous_gradient = self.gradient if not previous_gradient: previous_gradient = gradient + gradient_error if gradient >= (previous_gradient / 3.0): # gradient is negative! gradient, gradient_error = self._get_gradient(2 * step) # If still no meaningful gradient can be measured, try with # previous one, slightly shallower. A factor 0.8 is not too far # from what is expected from geometrical sampling steps of 10-20% # and a deVaucouleurs law or an exponential disk (at least at its # inner parts, r <~ 5 req). Gradient error is meaningless in this # case. if gradient >= (previous_gradient / 3.0): gradient = previous_gradient * 0.8 gradient_error = None self.gradient = gradient self.gradient_error = gradient_error if gradient_error and gradient < 0.0: self.gradient_relative_error = gradient_error / np.abs(gradient) else: self.gradient_relative_error = None def _get_gradient(self, step): gradient_sma = (1.0 + step) * self.geometry.sma gradient_sample = EllipseSample( self.image, gradient_sma, x0=self.geometry.x0, y0=self.geometry.y0, astep=self.geometry.astep, sclip=self.sclip, nclip=self.nclip, eps=self.geometry.eps, position_angle=self.geometry.pa, linear_growth=self.geometry.linear_growth, integrmode=self.integrmode) sg = gradient_sample.extract() mean_g = np.mean(sg[2]) gradient = (mean_g - self.mean) / self.geometry.sma / step s = self.extract() sigma = np.std(s[2]) sigma_g = np.std(sg[2]) gradient_error = (np.sqrt(sigma**2 / len(s[2]) + sigma_g**2 / len(sg[2])) / self.geometry.sma / step) return gradient, gradient_error def coordinates(self): """ Return the (x, y) coordinates associated with each sampled point. Returns ------- x, y : 1D `~numpy.ndarray` The x and y coordinate arrays. """ angles = self.values[0] radii = self.values[1] x = np.zeros(len(angles)) y = np.zeros(len(angles)) for i in range(len(x)): x[i] = (radii[i] * np.cos(angles[i] + self.geometry.pa) + self.geometry.x0) y[i] = (radii[i] * np.sin(angles[i] + self.geometry.pa) + self.geometry.y0) return x, y class CentralEllipseSample(EllipseSample): """ An `~photutils.isophote.EllipseSample` subclass designed to handle the special case of the central pixel in the galaxy image. """ def update(self, fixed_parameters=None): """ 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_error = None self.gradient_relative_error = None def _extract(self): angles = [] radii = [] intensities = [] integrator = INTEGRATORS['bilinear'](self.image, self.geometry, angles, radii, intensities) integrator.integrate(0.0, 0.0) self.total_points = 1 self.actual_points = 1 return np.array([np.array(angles), np.array(radii), np.array(intensities)]) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1739907483.705927 photutils-2.2.0/photutils/isophote/tests/0000755000175100001660000000000014755160634020213 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/tests/__init__.py0000644000175100001660000000000014755160622022307 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.7079268 photutils-2.2.0/photutils/isophote/tests/data/0000755000175100001660000000000014755160634021124 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/tests/data/M51_table.fits0000644000175100001660000006250014755160622023524 0ustar00runnerdockerSIMPLE = T / file does conform to FITS standard BITPIX = 16 / number of bits per data pixel NAXIS = 0 / number of data axes EXTEND = T / FITS dataset may contain extensions COMMENT FITS (Flexible Image Transport System) format is defined in 'AstronomyCOMMENT and Astrophysics', volume 376, page 359; bibcode: 2001A&A...376..359H ORIGIN = 'STScI-STSDAS/TABLES' / Tables version 2002-02-22 FILENAME= 'M51_table.fits' / name of file NEXTEND = 1 / number of extensions in file END XTENSION= 'BINTABLE' / binary table extension BITPIX = 8 / 8-bit bytes NAXIS = 2 / 2-dimensional binary table NAXIS1 = 160 / width of table in bytes NAXIS2 = 52 PCOUNT = 0 / size of special data area GCOUNT = 1 / one data group (required keyword) TFIELDS = 40 TTYPE1 = 'SMA ' / label for field 1 TFORM1 = '1E ' / data format of field: 4-byte REAL TUNIT1 = 'pixel ' / physical unit of field TTYPE2 = 'INTENS ' / label for field 2 TFORM2 = '1E ' / data format of field: 4-byte REAL TTYPE3 = 'INT_ERR ' / label for field 3 TFORM3 = '1E ' / data format of field: 4-byte REAL TTYPE4 = 'PIX_VAR ' / label for field 4 TFORM4 = '1E ' / data format of field: 4-byte REAL TTYPE5 = 'RMS ' / label for field 5 TFORM5 = '1E ' / data format of field: 4-byte REAL TTYPE6 = 'ELLIP ' / label for field 6 TFORM6 = '1E ' / data format of field: 4-byte REAL TTYPE7 = 'ELLIP_ERR' / label for field 7 TFORM7 = '1E ' / data format of field: 4-byte REAL TTYPE8 = 'PA ' / label for field 8 TFORM8 = '1E ' / data format of field: 4-byte REAL TUNIT8 = 'deg ' / physical unit of field TTYPE9 = 'PA_ERR ' / label for field 9 TFORM9 = '1E ' / data format of field: 4-byte REAL TUNIT9 = 'deg ' / physical unit of field TTYPE10 = 'X0 ' / label for field 10 TFORM10 = '1E ' / data format of field: 4-byte REAL TUNIT10 = 'pixel ' / physical unit of field TTYPE11 = 'X0_ERR ' / label for field 11 TFORM11 = '1E ' / data format of field: 4-byte REAL TUNIT11 = 'pixel ' / physical unit of field TTYPE12 = 'Y0 ' / label for field 12 TFORM12 = '1E ' / data format of field: 4-byte REAL TUNIT12 = 'pixel ' / physical unit of field TTYPE13 = 'Y0_ERR ' / label for field 13 TFORM13 = '1E ' / data format of field: 4-byte REAL TUNIT13 = 'pixel ' / physical unit of field TTYPE14 = 'GRAD ' / label for field 14 TFORM14 = '1E ' / data format of field: 4-byte REAL TTYPE15 = 'GRAD_ERR' / label for field 15 TFORM15 = '1E ' / data format of field: 4-byte REAL TTYPE16 = 'GRAD_R_ERR' / label for field 16 TFORM16 = '1E ' / data format of field: 4-byte REAL TTYPE17 = 'RSMA ' / label for field 17 TFORM17 = '1E ' / data format of field: 4-byte REAL TUNIT17 = 'pix(1/4)' / physical unit of field TTYPE18 = 'MAG ' / label for field 18 TFORM18 = '1E ' / data format of field: 4-byte REAL TTYPE19 = 'MAG_LERR' / label for field 19 TFORM19 = '1E ' / data format of field: 4-byte REAL TTYPE20 = 'MAG_UERR' / label for field 20 TFORM20 = '1E ' / data format of field: 4-byte REAL TTYPE21 = 'TFLUX_E ' / label for field 21 TFORM21 = '1E ' / data format of field: 4-byte REAL TTYPE22 = 'TFLUX_C ' / label for field 22 TFORM22 = '1E ' / data format of field: 4-byte REAL TTYPE23 = 'TMAG_E ' / label for field 23 TFORM23 = '1E ' / data format of field: 4-byte REAL TTYPE24 = 'TMAG_C ' / label for field 24 TFORM24 = '1E ' / data format of field: 4-byte REAL TTYPE25 = 'NPIX_E ' / label for field 25 TFORM25 = '1J ' / data format of field: 4-byte INTEGER TTYPE26 = 'NPIX_C ' / label for field 26 TFORM26 = '1J ' / data format of field: 4-byte INTEGER TTYPE27 = 'A3 ' / label for field 27 TFORM27 = '1E ' / data format of field: 4-byte REAL TTYPE28 = 'A3_ERR ' / label for field 28 TFORM28 = '1E ' / data format of field: 4-byte REAL TTYPE29 = 'B3 ' / label for field 29 TFORM29 = '1E ' / data format of field: 4-byte REAL TTYPE30 = 'B3_ERR ' / label for field 30 TFORM30 = '1E ' / data format of field: 4-byte REAL TTYPE31 = 'A4 ' / label for field 31 TFORM31 = '1E ' / data format of field: 4-byte REAL TTYPE32 = 'A4_ERR ' / label for field 32 TFORM32 = '1E ' / data format of field: 4-byte REAL TTYPE33 = 'B4 ' / label for field 33 TFORM33 = '1E ' / data format of field: 4-byte REAL TTYPE34 = 'B4_ERR ' / label for field 34 TFORM34 = '1E ' / data format of field: 4-byte REAL TTYPE35 = 'NDATA ' / label for field 35 TFORM35 = '1J ' / data format of field: 4-byte INTEGER TTYPE36 = 'NFLAG ' / label for field 36 TFORM36 = '1J ' / data format of field: 4-byte INTEGER TTYPE37 = 'NITER ' / label for field 37 TFORM37 = '1J ' / data format of field: 4-byte INTEGER TTYPE38 = 'STOP ' / label for field 38 TFORM38 = '1J ' / data format of field: 4-byte INTEGER TTYPE39 = 'A_BIG ' / label for field 39 TFORM39 = '1E ' / data format of field: 4-byte REAL TTYPE40 = 'SAREA ' / label for field 40 TFORM40 = '1E ' / data format of field: 4-byte REAL TUNIT40 = 'pixel ' / physical unit of field TDISP1 = 'F7.2 ' / display format TDISP2 = 'G10.3 ' / display format TDISP3 = 'G10.3 ' / display format TDISP4 = 'G9.3 ' / display format TDISP5 = 'G9.3 ' / display format TDISP6 = 'F6.4 ' / display format TDISP7 = 'F6.4 ' / display format TDISP8 = 'F6.2 ' / display format TDISP9 = 'F6.2 ' / display format TDISP10 = 'F7.2 ' / display format TDISP11 = 'F6.2 ' / display format TDISP12 = 'F7.2 ' / display format TDISP13 = 'F6.2 ' / display format TDISP14 = 'G8.3 ' / display format TDISP15 = 'G6.3 ' / display format TDISP16 = 'G6.3 ' / display format TDISP17 = 'F7.5 ' / display format TDISP18 = 'G7.3 ' / display format TDISP19 = 'G7.3 ' / display format TDISP20 = 'G7.3 ' / display format TDISP21 = 'G12.5 ' / display format TDISP22 = 'G12.5 ' / display format TDISP23 = 'G7.3 ' / display format TDISP24 = 'G7.3 ' / display format TDISP25 = 'I6 ' / display format TNULL25 = -2147483647 / undefined value for column TDISP26 = 'I6 ' / display format TNULL26 = -2147483647 / undefined value for column TDISP27 = 'G9.3 ' / display format TDISP28 = 'G7.3 ' / display format TDISP29 = 'G9.3 ' / display format TDISP30 = 'G7.3 ' / display format TDISP31 = 'G9.3 ' / display format TDISP32 = 'G7.3 ' / display format TDISP33 = 'G9.3 ' / display format TDISP34 = 'G7.3 ' / display format TDISP35 = 'I5 ' / display format TNULL35 = -2147483647 / undefined value for column TDISP36 = 'I5 ' / display format TNULL36 = -2147483647 / undefined value for column TDISP37 = 'I3 ' / display format TNULL37 = -2147483647 / undefined value for column TDISP38 = 'I2 ' / display format TNULL38 = -2147483647 / undefined value for column TDISP39 = 'G9.3 ' / display format TDISP40 = 'F5.1 ' / display format IMAGE = 'dev$pix ' END Eņ°˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙C€ņ)˙˙˙˙C~§˙˙˙˙ÄĪļ˙˙˙˙˙˙˙˙Á‰B˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙?__EÖĨnAˇ÷ŒBęƒGBĨĶZ=J‹=ŗ ´BSäB“°ņC€ņ)<ÁQ&C~§<Á[ÄĪ‚ĮD Ҍ>Æe§?Y~`Áy•;nĄh;mŅĄEņ°Eņ°Á‰BÁ‰B=¨i=8 –ŧÔÛē=›‰=H?į<ĖhŦŊ‡Ā„<õ¸! @)kM@?ĩĩEÔ AÂģoBø<\B¯‡z=#Ąb=­ kB@^-BÅLC€đ=<Íđ¤C}Ā<ÍŊ)Ä͆YDÆ7>ĀÉ?^ŧüÁDE;Ŧž;~ējEņ°Eņ°Á‰BÁ‰B=—”™=.v;ŧÎ'9=Ą&;úsx<‰ŅŊĒ;F= H @}“Ä@?!azEŅ‘ĀAäRžC‡BÍέ=J[=škÆB[wšB_ |C€íT<ôš~C~U<ķ ’ÄĘLTD#ŧ6>Ī3>?dųÁ";—ž;—Eņ°Eņ°Á‰BÁ‰B=´í%=G+ÛŧĪok=d=†›==Ô Ŋf*<<ã׀ A:Ē@?1„ĶEÎ} AâãŪCYBĖ„=eāa=¨iB=ĨB2Š5C€í<ôčC}<ô;uÄČ‚DŽĸ>ž!?iœÁÍE;™ {;˜]LEņ°Eņ°Á‰BÁ‰B=••]=+ļŽŧÉyų=MŒ:Ö<‡†ņŊŠÂ—= Šü @Ŗ2@?CEOEËSĖAņxiCčcBŲ¨=„Á•=ŖĖ)BHÉBC€ęu=ÕËC}=˙ˆÄÅŊD>šžË?o>6ÁˆŠ;Ĩm;¤ŠúEņ°Eņ°Á‰BÁ‰B= pD=(&CŧĪ(ä<õ×<ČĘ7<—ÛŊ2<ôÔ6 @š*ū@?VĖ>EĮįB\DC# ČBįg¯=•Û*=–¤SB86éA÷PíC€é=­ļC}=vÄÍm,DČZ>ώã?uÁ=*;˛ė ;˛Eņ°Eņ°Á‰BÁ‰B=‚l¤=ą7ŧÎYc<ņĪĢŧ …‰ĩ?zë’ÁÜÜ;­¤;Ŧ.ĸEņ°Eņ°Á‰BÁ‰B<ĮM;<ėˆ÷ŧģßÄ<ã&ŊaM<|“ôŊ“ã<ļ ? 2@ëė@?ķãEŋuAëÖCQBԔd=ŠHÆ=<ūĄAáZuAŠđC€į<ČÂēC~<ÎėÄõĶ^CÁä’>Ięô?€|CÁua;Ģéƒ;Ģ˜Eņ°GČÁ‰BÁ4šEŧAg<ĄÔžŊįŪ<ĨŒ…ŊCr-Bz?ƒ•kÁīą;•zŒ;”ÔPGČGČÁ4šEÁ4šEŧF”ŗ%E˛6oA¯Œ4BßĮžBž<=ž&M<Õ<–Aį)÷A _/C€é<‰ƒ‰C}´<Ži´Åš%Cs")=ę{&?†Á˛Á>Y;‰,Œ;ˆž@GČGČÁ4šEÁ4šEŧI‘ŸŖyÖ@?Ŧ÷EŠû9A™ō­BÄ>ôBŠÄN=ˇ'‘<ŠÃķAđ@åûBC€ęĐŲ"@?ŅJEE—žAŖmļBĐTÔB“P=ŧ]<‘_Aķ*@‰C€í#GR˙GR˙Á=LōÁ=Lō ŧ\=;îs“<(Á;äN‰<o~;Ô ģēŋø;ÉĮë ? 2č@?æ8E‹ƒA”ŧ|BŊš/B†Ž=Žu\}A >LB[lB'Ģ=Âc°<dAŽs @+J[C€îĮ<(*Ct›<,]ÄĨūøB–Ėæ=hļ?Ÿ7[Á <ü;R$Ŗ;QŽG°‡GŊ.ÁF>GÁGrēRv|;-WGå(HĻÁJÆŦÁMQņ%ŧ Û;_Ã<Í"8;sYĀ;‰ú ;QC@:$;Q ‹ ?ׯ@@`PæE ~f@ī „BAĻØBîĩ=Ęļw<2XAņî@UuÉC€đÉ<ĸa?Ch<¨cĨÄ ÜAķøG=`žœ?¯#KÁ2;l×|;l÷Ģ@@vŋdEž @čOyBDķeB Cå=ЧŨ„@@‡ļDíJ@ŲĸžB@]WBˇ=ÚøE<[4øBĢ6@q$›C€é×<ōQgCjå<ųT6ÃĻFˇAÁÜj=•<?ˇ¯§ÁÅ;’•;~ļ‡H9@H*”@ÁP)pÁQ°d19ŧ¸cš;ŸúģaõC~Z=?û5Ã`î;A°ĸ=ČiK?ĀĻĶÁ…Ą;ŸMt;ž’āHEH^@ÁT<+ÁVEĘIYŧæ_<ëŧFģĄ;îLŧÆÚ;Ŋ’ŧ7Ũ;­dÕ =éũ@@´Ą˛DŊ*ĩ@ū¤ŲBƒ=B9š!=ŋ‹€<ϐ€BđK@ŌĶ)C€Ø’=v—pCZ=yæÃ<ØjA§‡7=ã/?ÅLĀūe;ģŅ;ē˜6Hd €HiÔ@ÁVģ­ÁW*ū]aŊ<€ŧeą6<Krŧūęî;ūzį9ū`;â-)" >¸čŸ@@ÆąŪD¯•=A=(Bė¯BHļ,=¤S<ŗíOB ÕČA×5C€Ũą=‘ÃXCĨ>=”’Ã#%ƒA›C=ķ ­?ĘĀûÎH;ÎÃČ;͓@HzˇH…l`ÁXaÁYvdmyŊ <4¸*ģ1ĩ´<%^ŧúŸ<gGŲ@@ڐuDŖŲAĩIB™œwBY=1=–88<Øb{Aõŗ×A*2jC€æ(=ŧ€ŽCŧ=ÂeųÃUIAíz> Yä?ÎíSĀų=÷;čß;æ‰įHŽ” H”ē ÁZ™Á[YZ‡‘ŧꉃ<\Cä<3”īedÂÉIkA‚¯S>&5?ĶęĩĀ÷í;ū‡5;üˇaHŸß@H¨ĀÁ\šĻÁ]z]ŖąŧŠ<†Q<ĻiT<†ãø;{€<}ŧ<Ęô<„oã- >TčĄ@A;=DÛÆA|BŠ[ BoG={Đ÷=-ŨAVÕJAĸYˇCČ>4ŽaCø¸>=aũ† Au<˙>jh?ŲåĀôáÍ<]ĩ<hVHˇ HĀÅ@Á^õ†Á_ÚÖËŨŧ‰îq<ĩÛw9mY}<´(Q="Éč<ģš,ŧ2Î<ŽmM2 >įV@At]D‹ØßA6īBĀ˙ũBˆxš=ƒz=KÎÁŲBAļ¯ņCl÷>kNßC‚1Ģ>rŪĮÂ`@ A]Ş>}+Ø?ŪB ĀķæE<æÂ<˛sHĘē€H֚ Á`ēÔÁaˇũīŧŨx<ŌĖ5=A Š<áLJŊ|Īo<äGÃģĀ(M<Ā9Ā7 >xķŲ@A Dˆ0TA"BēRšBƒĀ=ĪęĖ=÷4Áž’úA3=ÃCŖœ>Eđ•C‚\˙>OÛzÂną€A9ĀÜ>G8ˇ?㞊Āōú< e5< Q&HáYĀHô Áb‘KÁcķÁ=ŧŠ_e<—ë‹={—<š%îŊ(xŪ<›Ŋ¯ģÞĩ<ŒsK<>%Ė>@A0D‚¨@ûüâB˛.ĢB{üâ>r˜<é/Á‰\@ŅW?CÄß>$|‘C‚kw>4OĐÂŽAīn> |¯?éČĀņ‰Ĩ<†'<‚áHųw@I đÁdUiÁeöÕGyŊ;G PĨ<Ī6fÁ­[8@ĄŌÕCĐ/>$&C‚jH>3‚.ÂkxADũ> ]˙?îēÉĀī—á<Žä<ŗæI 80Ik Áf>Áh`yŅŊ/žL<\Õg={C PĨ=$ h ëA CĐ/>“ZõC‚jH>˜5ž”AŠG>uĢU?ô|}ĀíRz< Ž<˙IlāI0épÁh|ÅÁjg™Ķ)<\’¨<¯ęl=°ŊU<ō\æŊARū<‘ŽKžØæ= !M2ACÂÎ@AjAŠDcßûAcäB˲B‹k>râÍ=&ÃÛÂ"R)@ŗĖÂCĢ{>Ž™qC‚?m>´SÁëĢB@˙„đ>ŠČ ?úaēĀėĮĐ<K]<ë I)`IJ*āÁiŸ Álš Ą<+\;<ˏĘ=¨ã =T$ŊG6<Ũģ‘ž "Ų=$˛ÕP2AÜ@A€×?D_îAÔ/BØĶØB™Qų>“ōf=†Â"R)@‹MÆCĪÕ>ˇ19C‚ K>žœ1Áã0°@ôįG>‰úĪ@5ŽĀė,Ž<&äļ<%VČI8ZĀIeG0ÁkÜÁnčŽI%ŊĖt<úÖ}=<.1<ā}RŧØ}™<Ē´OžB)=" ‹T2@ßy‚@Aš’D]ļÆAŗ>Bß_ūBķ0>ˇ <šÔ&¨Ę?īåĮCö8>W@7C‚DĨ>e"†Â9ŽG@Ö˛Ļ>O@M"ĀëÔ<)Ŧ<(äICÁIƒĢĐÁl)°ÁqP˛}ÕŊa9 ‘d@A›åēD<û'@ķ(đBájOBŸd†>6L <”7{Â[ˇ@KáÎCzã>M—C‚?m>BâEÂLyČ@ĩié=ã u@wĢĀæG<<3¸T<1ėúI„\(I–8ÁqgįÁsšĮ׹Ŋ!đˇ<+Šā=É<*ŸÁŧbš°<ėđŧĒđ<zÔn @O @AĢ|ŗD*ņ @÷ÛBí¤uB¨ ß>Zŗ¤<ŧ;•ÂY7Ę@\ūCB>“‚C‚%>ŠûģÂd¯@­ž…>Ī@ ĩĀĀâĘéô<{X2ŧ|É Ķč<šˇÂKā@YēC€ĀŸ>‹ņũCŽH>„î×Â1rØ@×ķ*>Ŏ@ ŲĀáõķ<üô<|\2I›Į˜IŧÁ Áti>a^ĘC;ø>XĄŒÂ?@ŽûÄ=ė7@nrĀŲŋü<]–‚Ÿ></ `4?öLš)ZC‚eV>Ą•ŲÁęÂŋ@Œ1ņ>āú@ę ĀÚ^?hûę@{ ĀĖŌ<ˆŊv<†ĢŠIúv`Ju<Á||=Á}Ü K ŧv;M<ûB=H7<¨mÄŧKÄ.^wŗ@";ĀȅÖ<Ž´J<ĢV\J 7¤JKČÁ~ēÁ~´[ ™Ą=`vÚ<ĪvŪ=˛øČ<ņ.ŧ„=<ˇäŊw< ÆĐ2A J @BæbCÍŅ@χĶBŪžŽB =ŠY=2ڔÂsčíA˜}ēCV¤?^œCđß?WūŋĀö{?a=éŧn@ßãĀÄ7Ŧ<¤¸ī<Ąē~Jé J›PÁšĪÁ€!‡ŧŖ-ä<ēÎę=Œ×ė<Å´ĸŊŠÜ<ĀáĐē‡û<ˇ:Čå2Bã@B'C‚0@Ĩn–BčB¤"k=ŠY= 5QÂsčíAo.VC€?@÷C„<›?:PBÁ 3o@‰>ovÉ@"´ŖĀÁ?{<˛_}<ŽŪ*J'<øJ,u(Á€ÁOÁĨûaŊu7}<ĩĸ–Ŋ“ķ5<ÆEŊ?ũ×<™Ŋū=đQX=¸Xü2B%@B7ˇCwŨu@z.ëB˛ #B|Z>-åX<ÉŊfBĄr8@éFC|Éa?*;+C‚û?\fÁļP?‰7Ô>^õ@&Ą Āŋ‰ˆ<dĀ<‹-˜J.48J<ĄhÁ ÁĖ÷Õŧ-)ļ-åX<ˇoÂąØg@ƒã#C|Éa?*tķC‚û?äz‡wž?nÎ> 2@*Ĩ¨Ā¸ōh<1’ŠJMšÜÁŨkÁ‚ßû]<ôĻ˙`HĩĪuC‚’Ĩ>¯%0Ā•¯>Ü%u=ŧAT@.ÃĀļ§™<„Z;˙3ÅJIžŦJ`‹Á‚a ÁƒP}%ų;d]#;ķöb<¤4 ;ûb[ŧ‰`ä;ë‘Ļ=! %<S3 ?poņ@BtĸėC?Ų?fōĨAÆöVAŒ°C~'f?[ĖC‚’Ĩ?XĶĘŋą‹˙˙˙˙˙˙˙˙@2ųįĀŽÔø;Ō;ĐŅJrļJtmŧÁƒũ}Á„ +-=-ņ;ßO'FæFähFߞzFŪ‘FÛĢFØuŽF՝ÆFŌA4FΚæFˆDFÉ\ŧFŝiFž’¨FŧĻrFēâcFˇWF˛˜IFŽMŦFŠjBF¨ :FŖFŸŅĶF›ôŖF–‘hF“ÃjF3ŦFŽ‚dFˆäĩF‡&ÅFƒ‘ĩF€W‹F "ÆF&˙F*ŸâF+ąF1H¸F34ēF6ąūF:íbF>7{FA rFFæöFJŗÆFO,˛FT3FVzÎF]ITF`ą¤FhyFlĐXFsāFy(ĐFFē_F„3tF‡ÖęF‰QÂF `FēBFœ™ÎFĸwÜF¤°FŠ€F­/™F°0æF˛|äFļæ’F¸ļDFŋŸZFÃ8FÅđĄFĘ ’F˜ĄFĪÔ¤FÕŌrF×YāFÛH-FÜ´UFáÜÜFäAgFåĢ…FæÛEFéXŽFíKcFī?'Fī2Fņ÷fFņ…YFōFíAŠFíQFčHSFį2:Fåo×Fã’LFā,FÚú%FÖ+áFԙüFŅ$âFĐXÔFÉėFٝFÃX‹FĀĸ9Fē°ĒFˇ9Fą€#F¯ÁIFĒ1ŽF§ éFĄø˛FžīģFšč F—ˇ-F“\F{ŊFŠ}­F‡‚šF„A8F‚wF$áyF'îbF*ÅF-ÃõF1D™F3•ŸF:ŽĄF=¨FBŽFEĖĪFGÔ[FOnÁFRLøFU‰F[ųŒFb$FcļFkŌÃFnÜŽFv­F}ÂÂFF„;F…•WF‰Ŗ‚F](F‘Í÷F’˛UF–ļlFšmžFÁEFĸiFĨ3FŠ‘+FރiFąúŒF´1áFšcčFŧįäFĀD%FÄ^=FĮĻÎFËŋFĪŊFŌžRFÖaFÛ FßŋĨFāšņFåQ$FįJ8Fė Fė$FņœŽFņFļFô@ōFö¯TFųNFû5aFũ!ĪFú wFų`ÖFũœ˙Fü™ĸFú¨šFųÁ)FúEŨFú 4F÷[Fķ:ĻFō˜ĢFđĢFėÚâFęĀÛFæø,FãŨíFá5÷FÛ˛F×CFÔøFÎP,FËÕDFÆIôFÂHnFžŋeFšČėFļ˜DF˛ÂFŽFŠ|0FĻŗ‹FĄ_FžÁ™F˜I†F–<_F’NFŽˆ’F‰ōF‡ôIF„@F$Ā\F(+ŦF,kĢF2"JF4O’F6*ŖF?jpF=ī|FEŗFGĀcFL#yFRú|FUāĸFZ+EFaŒWFdmĨFnéžFnø^FqūĸF|ÄņF€õčFƒûčF‡KÁFŠyĀFŒå#F‘uF’íœF˜oōFšÃFžĨ­F ­ŖFĻn´FĒspF­<F˛šôFĩ‰‚FģzQFž¸4FÃ\ũFĮ¨NFĘB[FĐyfFĶ~‰FÔ-CFŲģ#FßlFä HFæūFęŪėFîŽFFņđŅFōaÔFôúF÷œdFû(ķFũe‹Fū†qGŨ8GÃGērGSGpGĖ_Gw]G˙ÕG…CGŠ(G4ĐFū\yFûgšF÷ģ|F÷×FōĖÅFīķ¸FîāƒFéĒÅFäUPFâq FÛÂJFڒvFĶáFŅî‹FĪ$úFĮ[ÖFÄë>FŊŒ°Fŧ@IF´ˆ F´OˆF­õ•F¨ōęF¤‡%FĄ´ßFCÁ›FHø—FKešFTČ;FS1AF[Ž#FbZŽFc}~Fj0ŅFrPôFvüF|ŧF›pFƒŦ?FˆAFŒ€cFŽSAF“P¯F–$uFš•øFœĒčFĄ‹õFĻÖAFĒ}/FŽwiF´ pF¸}ūFŊHFiFĮlĄFËqŖFĐ7FÖGļFÚqFÜūFāĒFč FëƒÄFņãJFõãÔFøé FūõGHuGÅG¤ņGÜKG {G ~-G 8'G ZŠGØG’ÖGxŒG¯G[zGruGĀG`›GrGpBGüGG1"G ?%G ļG dGHGŠēGįúGÜFũ֝FųĶFôį|FīĨĖFęôFįõîFās@FۙF×ŋFŅķFÎOFɂFÃĖŌFžĸFšü…F˛ÖF¯y=FŠ6FĨžļFĸԎFŗF˜ĐĶF•­FúFô FëZFæŠ7Fß-ŦFŲڀFĶÆˇFÍkbFÅ*ÚF€NFŧŸ^FˇfũF°ļ.F­ŗ FĻmFĄŗžFvĸF˜ÅôF”i1F4ŅSF8¨æF@yFDZFF3FLOFQ,RFWh\F[ęuF`Ą‚FeōFmŒcFrŽ]Fu¸F€yF‚ÁÕF…xRF‰¨ĩFŽƒFŒ;F–>’F™oŸFž:FŖ„ÔF§ ~FŠũ„F°c=FļhĄFēZ_FĀ@|FÄÍVFĘĩôFŅá#FÖgŽFŨQFæC$FęTęFđ(Fö†EFü šGĒGu÷GõG GÛZG÷ũGéŊGGĒG?ĻGŲPG!‰ĢG$‰´G&ŧíG)H=G*xžG,kžG.îŒG/JG0ũG1jG1HG2LõG0|ČG/ũ2G/9~G-ŪYG,ŒTG*RéG&ĸæG#ŪĀG"BGÜPGŒ:G0ĪGGŅGdG{dG é˜G Ŗ.GH'GzŠF˙ļ˛Föƒ—FđÂFę(ˆFäĩ"FŪ FÖ­EFŌ…žFËŋ FÄp Fžę€F¸ƒ÷FĩĄeF¯‹ĮFŠ’ŦF¤‹íF 1qF›ĀgF– F7ĮqF;öFCŧOFDŠFM¨rFP7°FTā‘FX Fa mFbĨoFi‡&Fp+Fx.F}jFƒ F„Ũ2F‰…BFŒ„$FŽ6ČF• ŋF™ŅXF]­F [zFĨŖeFĢÆF°-F˛ÕÛFšNQFŋ;IFÄážFĖˆFŌ>=FÖt˜FŨ”FãÎÂFęč|FīWGuĒąGtƒûG@ڇG@AG?I/G>e4G<õÄG;öŪG:ėDG8/ŒG4;ÕG/ä1G-ĩâG*Ģ'G'‚G$­$G ‘Gų GėGXžGÁEG îG`ĖG-YGÉĮFûN°FôFFëm5FįDaFāĨOFÚ?›FŅ|ŲF͞îFÆPaFĀAFģOŌF´FŽ"YFŠr´FĨ”ûFŸ%Fš8Gw„ƒG{XÖGuĄÄGwQGMĐČGHąˆGFĨGEų`GC#°GAÜ}G?ĪG;GvG:G5ũ~G1ÄQG-ĨG)NˇôG@ÍFGEÎhGI PGu¸GxuđGoģGnk[Gt™Gx{”Gxz!GS:ãGS‘GOûøGO-ßGKĐmGKbúGGÔGCnG@ĻĖG<lG8}G2ã]G.ëG*ĢÅG%ã9G!ėSG{G ĖGī§GôĪG ĨĪGäGFūMäF÷ˆFîŠĸFæõ˜Fāe{FŲ.ËFŅÆÎFˎFČ‚FŋĀ5F¸áFŗhÂFŽŪ7F¨ķŖF¤-FßTF?ŖFD”^FHĸ$FOĻŗFU?ĩFZâ–F^ÜØFfôFké_FpĢiFxnyF€ĨoF‚iF‡ĐF‹3ļFåF‘.;F—áFšÎ%F 3ŗFŖiFŠđ_FŽ ŠFŗ×F¸üFžíFÄäÂFË~+FĶ+FØBŠFā ‘FæúoFėąF÷îFũu†G­cG›šG Ķ’GŦ_G,G —GÄ!G!ĖG'R@G,/G0G1G5M+G9Ā(G>ĒķGC1qGFuŖGJžFGO ŋGB;†GI‹GO˜uGS–JGY2QG]fōGmûGvlųGvGƒGr;´Gsr4GxzÉGx|6GxzZGx{…Gx{ˆGx|/GnâHGn&GhęŊGcü´GaŊ,G] ĘGW>˜GSU˜GJšGH7GB=!G<„G6ŋ§G1G,)G%GŽG ÆGN¨GFäG2ŗG ‰GčÍGÆ5Fũ !FôƒšFëcķFäQáFÜëFÔjÍFÍŨDFÆJĻFÁĶĖFšbŦF´EžF­&F¨÷xFĨFųFE/üFH°°FN8ŽFT—ĶFY5‘F`˙öFeÚˇFkėDFrîÃF{ĐuF€ēŦF„b0F‡ėkFŒŌFú5F”û!Fš>€FœØũFĄ/1F§8JFŦëFŗĐŸF¸ZZFž1^FÄëčFĖrFŅU‡FÚAšFā{ĨFéGFī4ZFų%GW%G˛ĩG ū¤GbcGËGօGøˆG"g°G(G,õ–G4<ŨG8Ė­GB pGF)ĀGLT&GR/7GXÃfG\¸¸GcŖËGiđßG{âGtQŌGwr†Gq÷9GyÅGx|aGx{šGx{GxzŲGxzGwĖGtˇšGpÜĢGqO-GpF‹GiėdGe>GaŽąGY}ÔGUCŽGNåŨGH´ĐGAīFG;G6G/ÔųG)“$G$2pGļŌG͈Gą8G ā§G š•Gz„F˙íæFöpŠFīĩŠFåÔëFá0fFØBFŅDŽFÉ FÁ×SFģđ3FĩČF°‘ÚFĢqîF¤=ŽFGFKŒīFRÔÖFVqĖF]Õ/F`)IFlvOFn͝FxšŊF}‡F‚iF†m4F‹ē˛FŒī'F“ĄpF—5F›,­F 3FĻē\FĢËvF°•ĻF´•7Fģ€FĀŌTFČbËFÍŲAFÔ¨„FŪ‹FåããFėŒ"FôÍFūĖGWyGēĮG å‘Gž=GbĀG+cG#HķG'ûØG.ŽG4<ļG;UGAEŲGGÆúGNëĢGUrĘGX˙sGbęĨGhx8Gn mGtl,Gr› GvuGv„=Gn3GwĸGkrãGx}ØGx{´Gx{=GvqGr`^GuđÂGrS}Gu<ĒGu‹Gs•™GpÂGiđ1Gd! G]GWöGPX/GHņGB/qG;Æ G46ôG.ė¯G(ŋwG!yøGA€G¸ŽGQæG õíG~;G›ßFūœĩFôÉFé2KFāņéFŲSÄFĶ*FËFÆFVFŋÁFˇ[F˛WFŦr˙F§ đFI+FO*ëFP§rFW0ØF^|ĻFf­ŸFjęPFpwúFwa”FžŽFƒÂBF‰ ŧF‹ŠFUĸF”ŠF™]uFūFĄ×¯FĻ’ŧF­ĘmF´íFš—SFžœwFÅŖžFËÅFŅëÉFÚ××Fâ|ĢFę¯FōôAFúĖīGëG)ÄG ËöG8G˜ęGØĪG!ĘnG(čÕG.ˆOG4š*G:æNGA;GIĘrGN˞GVîzG^ÃŅGg/æGlہGrõŽGxr:GrGtaBGpčwGs¤ëGo}öGsQGyAMGz;Gv#—GnėÛGwFGwFGv‚[GprSGnâGy,GqĨGtG;Gq‘-GmJČGdĀŊG^,RGW2!GM÷ĸGFįÅG@šNG8˙ÎG2žG-¨žG$s.GĶpGW.GgųGG {nGí’G FōčkFí ¨Få2úFŪhŠFÕēFĪ5FȔ•FĀčÍFēFĩ8šFŽF¨ FHLÜFL¯eFVC>FZĖĮFb6ĮFfõ¨Fl×bFramF{ÂÄF‚īųF„Š=FŠ\8FŒĒíF’ÆÔF˜×"F™‡˜FŸôrFĻž÷F¨KLF¯QōFĩ€Fēš^FĀčāFÉ2ĶFĪÛ¨F×ņˇFÜFåëíFîFök=F˙•GRœG ŗG#GÚGÛG ÖG'm]G,ĢcG2H G;}G?čGHŽtGOč™GXŽ–G_{ĀGgÍīGpÃ0GwtGwĪQGplŲGq“ĻGvÅGu?QGsĢ!GsņFGtoOGoxßGo¸3GsÎüGxtXGnÚGqøâGpŋąGpN›GwĘįGyViGwTGw<ĢGmœįGkō0GmŅGd^úG[`GV…ˇGMŽžGFÚĖG>–G6,ˇG/¤VG*bVG#˛žGu5GŪ…Gˆ"G X8GHˇGJFųãFōē†Fį҃Fß|SFÖÄFЄFČŲ*FÁžëF¸cQFˇŠķF¯9ŌFŠ2ˆFLA†FPXFW>ÉF\‡Fg&ũFlfŧFmõ•Fyė#F~SF‚ F…YF‹9FÅ$F”w0F•Ü F•‚F ŽōFĻåâFŦ´ÂF˛…ãF¸šÎFžÄGFÄYRFË9 FԁũFÜŅ˙F㙠Fę}‡FôģFūX’GžÂG}áGČYG|øGČGđG#´G)‹G1“ G8ëG?oãGGžŗGP:HGY G``ĩGg]ÚGp~KGt?GtJČGoųõGo‚SGs+‘GrQŽGpĄGqrnGzžûGrĢļGv¨€GsŽEGsZāGoy!GqwaGw\íGu˜ĄGx–GvswGrxÉGnŧãGrĸĀGp GkŠÎGtûŪGlß GbĐöG[CGS1ĪGKx9GC§G;4G4ŧëG-"ÖG%fGåÎG‰xGEG DGĮ‰GÛˇFũKāFôÅÎFëdˇFãęÚFŲžœFŅáFÍYÂFÄØƒFŊđFļÜbF°ÕžFŠ#øFJę”FTiûFYq]F`-ÅFdė˙FmcFt1‚Fz‚ūF~ũåFƒĨFˆŊ/F‹KdF‘|‡F”øüF—Ú/FŸD&FĸäÎFĒ5öFŽĶ‹F´øâFģļFÁ![FĮOFÎPÖFÕķjFá~HFæ™ŪFFųoCGyJG÷šG AzG˜ÃG‹GŌ]G!ÚÉG*#7G/ã?G7ŌŨG?āGEômGO‹GVÉJG_įGllGqÉ{GošÖGww0GnąGv*ŅGq\BGod0GsŅGw‚ĩGsŲģGsŒ¤GrL—Gna‚GqéēGs pGxĶGniGu…GqáĖGsuŌGz}Gs4Gv(GvŌGyvôGv~hGwŽGsÁGkn"GaŽ8GYOGNÚgGH@G@ûG8~AG0ZG(ŖÄG"#9GŦƒG>ĐGjFæ†ŧFņ=F÷eG<G2ŋG ˙yGG?īGōXG#ãōG+™ģG2Ņ‘G;8GDGJņĘGTžG^lGhölGo‘Gq*Gs•mGr˙gGrAsGp Gs—Guķ“GqGvž}Gq‡GrHâGsŠGu×)Gu_7Goĩ˛GsÉâGtģŋGxō|GuĖGnŨčGv×ôGrLįGrięGsYG{r`Gn…ķGskGvôGwí GuŨųGn‡&GcX7GZ-ķGQ´ÖGFķG?ĒÍG71øG/EG'LŅG‰AGBíGxGŠĩGĪÔGBôFûFđ;FęQäFßËqF؞ôFĐnFČA9FÁ…āFšûžFĩąFŽ#…FRüsFYņF^7ŖFa™ÁFlĖFpØFz˙ŸF}u˜F„Č´F‡ÕkFŒZũF‘ųGF•ÅēF™™šFŸFFĻUÅFĢ äF¯ė2F¸*ĢFŊQęFÄØ_FĘaFŌ!FÚΊFāTõFëQFõüŦF˙YãGo,G qŲGødG &G=ƒG"G(âÃG/ņÁG6vG@S=GJŧXGQ#æG[øGdžÜGpŠHGv‹GxØÍGx|{Gs$2GzŲ6GsÆ Gu´GGwläGw×ÕGv]˛GuĪGrסGsĩŦGrú{GsÂĮGxGp^GvŽ^GmOÄGppGvđGwDGvũėGtiÃGrRžGpŸGr‘hGpp GueîGtjG}Gs3ķGheGG_gGT$GJĶ[GBH4G:ĪG2ZwG)_ G"„CGŖ_G‘GæG s´GĖfG‹Fô÷0Fí+cFã‡ÜFÚßQFŅ1›FČĀRFÂ>‰FģēFĩ sF­?§FUĮaFUũ:F\LFi4ĖFjÉĸFt4F|æ”F€ņæF„ę÷FˆŌØFŽæF‘Ų|F–y\F›Ų FĄ"æF§ûFŦ/FąŨČFšE­Fŧ‹OFà FĖ&|FĶÛßFŨ?GrÁZGwũ¤GwĨGsÉ˙GuŗXGtĀGv]yGpÔŦGvOĮGp]Gb?GXŸGH†G?2/G58ôG/žŲG(kG~‘G ›G¯G gGggGąÃFøR!FîĶFãŋ*FÜãFŌißFËwsFÃĀFžã—FļWTF˛–|FUw&F\käF`gĘFj7õFo­FwÛãF}$JFáŧF‡yÜF‹@FŲ3F“į|F™2Fž=BFŖb'FŠqKF­×ĶFˇaŨFģēFœFÉ߅FĐõVFŲîFâúFęyļFô\F˙uûGōļG ΤG¯æGS"GįäG#ō:G+ĄG2ßÛG;ønGDPuGN°4GX¤jGdj´Gp%Gsf‚GsŸGsöpGoØųGvÔVGonGtLqGsšFšuÆFŸBFĨX`FĒbGF¯ĻķFļÜFŊ‘ÉFÆ1kFĖRČFĶ .FÛ]FäͤFíPFøŨ9GđÉGIāG s™G lGŗ G´LG&´ĀG.ûG7ãGAŖ)GJ;hGTÛTG_9œGiũ&Gtd GrŨKGt GoĄ>GtĨ GvÎ:GuÕGu•ŠGu”GuÖ%GuGvܰGu#AGv¯9GsTĸGqf Gx4ûGx@7GvQZGv5rGs„ŠGwåŌGs)lGvž|GsžĖGo•GpĶ,GrącGsSOGnąGqu˜GtŒDGz‘GwŠgGoœnGuš›GoOžGcVļGXZÅGM‚GEA‰G:U¨G3¯ėG*EĪG"YGü4G]øGĖLGÄGŨ`FúŽŖFņ+˜Fæ =FŨ FדFͰbFĮņÛFžĒFˇųČFŗĻFYĨôF_OčFeĻEFk÷VFqĪnF|O‹F€WØF„ūF‰éØFŒĻČF‘äãF•ëF›ÜËFĸ>F§2ÜFŦvFą`‚FˇJcFžņÛFĮŲ­FÎ"}FÖėFŪ1=FætCFīžFũúGŒÛG gG †ˇGGÚ-G"‰ČG*w'G3ĮG:5GC'~GMņįGX…ˆGcW˛GpËōGreGlŊÆGu2ŽGrW˜GjŪēGqØGqŦ?GrϜGuIGpGuâqGušāGm¨/Gs,ĻGnœRGr`ˇGv"÷Gm6‘Gk‹6GoakGnŗLGs;wGuĄGu|îGoˇęGuB[G{, Gu˛GvTGqsWGo">GtaøGr…GvmGp=?Gpū Go{jGgGZĀņGPW—GEėG=iŊG3‹|G,‹hG#üųGŅGTŽG´ņGUG\GFû{1FôCoFémbFßZxFÖxkFÎ}FÆÛxFžŌ„F¸rËFŗ]eFZlbF_˛1FeäTFnŽFveęF|mFŧ˛F…ÉĶF‰ŧiFŽiFk.F—<ŖFž=ÁFĸ[öFĨāíFŦ÷˛F˛ė-FšÕUFĀhFÉuļFĐĄ5F×ĀFá‹ĸFëXFō÷¸FũoČGŠĶG @‡GãšGü“GŅ'G%wĘG,ĒG5_ƒG>\¤GFŊPGRēŨG^u;GjÜLGsū>GqŲHGvƗGvģGzšGrˇVGqküGtGwķ Gw"ŊGlđMGvĢŲGx‹ŽGv[ Gu–ĻGx™]GwÂGr˟GtĨGx:Gs:ÉGp0ŖGríG{GthOGsYļGt)GvČ÷Gu:GuņŦGsÍGr‘dGo]GxĻGtĸŋGxáPG{ŽGGtŗ‡Gg—G^}=GRÖGF@(G?qG4•G,ŪšG$ãäG[8G“GGŦG ‘GĖFũĻ FōáuFéRČFßytFØÕéFĪw;FĮüĒFŋ‹gFēiÂFą‡ÍF\œ8Fa+’Fh՚FlņžFtFŪF}PĩF‚u¯F†ßœFЇFōcF‘,ÁF™WFœwļFŖŲNF¨YfFŽH‘FļMXFģƒ=FÂ.,FĘ9FԌ"FÚÛąFäJžFīk°FōÆFūĘVGÅîG ĐŽG0ŪGĻdGbVG&ņĸG/§'G8‚ˇGA\[GK´|GV DGb¤ GnÍGtGwÅëGwÄGqeGvOĢGtæGrqcGr—Gvl^Gp…&Guĩ Guī Gv÷LGv(—Gs0ĖGsįDGxQGn ĢGv=°Gs¤Gv‡ĪGpÂlGvږGuXGGz6GGrMÔGp—@Gs\˜Gu2ĐGxGqܰGvĖžGs´÷G|~GpōKGt=GsčŊGwgŲGlÍÜG^KGS!dGJ9G?ā¯G6ĄG-Ĩ¯G$×PGĀęG'GVųG ‡;G“F˙§æFôJ¤FęHFâ}FØ.MFĐEUFÉǰFĀĨÄFē—FŗT\F]ļ‚FbÛFiđûFmÎFvĶF{XDFƒÛF†¯wFŠīUFhãF”7sF™uļFŸėF¤ ûFĒõ[F¯™@F¸”kFžŊFÅ AFÎWĨFĶM”FÛ˛FæHšFî‰UFųČÕGÔÉG͚G ‘'GtÄGxŗEGt’ˆGw~Gv„ßGv?ƒGp˙LGvĢíGwP¨Gt]ĩGs:ÍGz*ķGnņ,Gu$GsyúGsũ^GuÆpGsÛ9GpėÁGrPAGuŒ–Gs¯ŒGv›ĘGsęļGq4{Gz8`Gwû\Gv]KGviGqŒôGx œGl%G`Û¸GUœEGIķJG@j”G7.ÛG.0G&ĢcGŽ4GĒtG€cG ĶG)ëFũEįFö#zFëúīFāõ…FŲ#|FŅhŠFȓSFÁŖŠFē%Fŗë5F^_ÍFdJšFiõ­FpX7Fw2‘F}x‡Fƒ›NF‡FŒ’oFã˜F– hFšXDFŸÖwF¤ī”FĢEcFąÖ&FˇCŌFĀRFÅÔFĪį FÕ ¨FÛ¯CFæE FīŋÎFújëGüÅG bGØGŋžG`G$(-G+AīG3z G>DdGEūGQ>G]ŧ‰Gi“Gv>¤GtĘÛGuZĐGp–úGvĐmGpÉoGw§‹GqwGpöĨGpÎDGohęGsĢuGoņųGsõG{˛)Goö™GpõīGqŧGsrGp ¨Gz|ėGrÎčGtŧ4GwΡGs ƒGsæíGy6įGsČGp—™Gol7GmîĒG`íĀGTļ7GKm7G@GtļôGqžYGr¤UGnæ°Gv¨GmGPGmāÄGbxRGTŧGJĀuG@Š”G7HËG.G'|éGâGCũGjūG ĸG33G­FķØûFė ņFãŸßFØd×FŅ"fFČņ˙FĀoVFŧŒ9FąIF_–FdˆŦFk†3FrĖMF~™pFĪF„ܧF‰ŲčFŒÖĖF’ēŖF˜xšFœ†F #LFĻG&FŦr-F´z×FšãFÁ`GFÉĶÜFĪ(™FŲ¤Fáy‘FéķēFôhYGG”‚G 0nGíGŲG!R[G(÷īG/3íG8ōyGBįŗGKCœGVʂGa6GqÁgGvŗnGpĮŅGow˜Gk‹ėGuæGtm’Gq/TGzšŸGu“Gt/7G~}7GrEGqÆ8Gwũ‰GuâÂGvŠĨGtåÔGoŸ.GsƒgGpŌ,Gz7ņGw2ĀGw—GnhōGplĶGoûGr§÷Gr{\GqŗÃGsŌGsßGv4(GoŦfGm°ĘGwUGvząGv˚Gq°šGsÚ Gn^‚GbrGV ģGJ<ÚG@ƒ(G7XĶG0,ųG&ũhG¨Gë G4ķG GÅG—ôFū)ļFķž0FégFā#[FØáØFĖĨyFĮyFÁéúF¸ŧĢF˛žéF]ÛFfm€FjsøFsĪFz2bFųŋF…žčF‰îÚFë$F’[ŒF—ėuFœ_žF¤ƒFĨhFގKFŗÉčFģaįFÁžFÉ~>FĐĢFFØ+¤FâĶFėĢAFõ‰GA°GašG Ĩ7GŲĪGÚ(G"ŨG+6¨G2X‰G;C^GD6GMrĖGWq~GeI[Gq—KGx“ãGmÔūGnŨGuCUGqŅēGv,GnōGr8dGr-QGk’kGq!ÕGsÉGqxGsÁžGn)6GtˇPGu’íGuļĄGv@Gt–nGqe¨GwGpä GtuGtÚGqäAGq’ĖGq[GrGw“†GoßEGo9ĶGyíGn¨1GwņG{ÚpGsæĨG}ÅÁGvęHGn{°G`ą GSÕ¤GJŌëG?}G7sŅG-“ĩG&šGŒ GĐņG{ŋG JGŠõFūōFFķØĮFé‹Fá¤ĨF×wÍFĐ+0FČqšFÁ€ųF¸#2FŗŊVF`OÕFi'…Fl8ŽFtęF{€ĀFƒ;œF†ßTF‰ã]FŽMÃF’ĘF—…‹Fœ'ûFĄÔ¸F¨JöFŽ>AF´%_FŧŨœFÁįFĘ xFҏĀFŲ§ĘFáT–FėŧhF÷eÆGUœGßG 4GšīGžG$$&G,vG3HŋG<ųšGDĶŠGNbŗG[ĸGezGqė_GuķSGu'UGvīCGzsrG~S|G|qåGv%GvɯGE+ŋGQ7&G[Q…Gg'‡GrŲGx’GtŊŠGq9„Goķ×Gt÷…Guƒ"GsŽ"GqĐGq™×Gxb}GußĶGsåGqSvGrĮļGqŖMGvWGt’YGt¤GvŖ2GsŦüGto>GrtBGuģGvVGsšūGrîĄGtŽģGv)GpäOGrĨāGv] G{šGo. !G5_˙G-<ĻG#pĘGÂhGî~GÃČG¨GūôFütEFō`hFéBjFß BFÖ¤FÎ÷ßFÆđGyē­GsgGx&uGrJąGtŸˆGvrvGw—MGsÎÅGqGsGwWĪGrÆĸGu?Gtō Gw~ļGxŽŪGtjÁGoT¯Go_GuÖFGpÔWGu*°Gt(ŪGqƒĢGmQDGx›qGpĮâGiĀG[ø)GQĐFGF!GG=eĀG4ĒiG+x”G#úęGT÷GøGæG †Gņ6Fų5Fō8fFįmŨFßŋ•FÕ#!FÍSFĮ‰ŦFŊŦFˇ\ĶF˛F^ŪĖFiGXFoÚCF{0úF×ŅFƒĐ[F‡”F‹æßF.F“+ûF™ŨFœ˛ŖFĨöŨFĒä‡F¯EšFļ VFŊŽĪFÂpæFĖ!ÆFŌaFÚˇFå=FđĸVF÷$¤Gb@GiG GÍ4GĨG#Ķ‚G+đåG5IēG>ãGE׉GQą G[ŨGgåGtöGrŸ€GpËŌGyS…GrŅĮGlœ˜Gy#…Gp%ëGsÉ FædƒFíįÜF÷îÃGˇGwųG ņGü?GâG"KÁG)=‹G2x-G;ĀjGD UGLôpGW‘EGdHÖGoAĮGv]ãGqÜ3GvÚģGrÔÃGv!ŖGqˇDGrĢAGxĻåGqūNGtuôGtŪŠGu.FGsøäGxGs››Gyá/Gt'‘Gq&CGt¸#Gu´ėGszGuƒGqrŋG{~,Gp3GpņôGpäpG{¯'GwG|GqgĶGuÚ;GtGtīŖGpmĄGtZGxjŋGyi\GmzGbˆëGYh GOųGEĀøG;ŲXG1i~G)ÛG"‘˜GîmGÍxG _‘G›¯G$FøëØFīō_FįIßFŨ÷,FÕ§{FĖÍŨFÆĶFž^F¸@ÕF˛âÎFLJ÷F`‚7FfėFpHFtj F}ū1F‚ë›F†- FˆāņF]ĒF•0ķF—PGFzFŖ×ŌFŠÄyFŽŽ‹FˇåFžŪ>FÄ0¤FĖFŌ¸FÛâØFᮗFí|}F÷ˇlGß~GTëG %kG• GžG _G*)áG0ÉūG:xËGBÕWGM6!GW[ŦGbúAGmėúGqMÕGtnGwJŊGs9Gt”Gq&Gxė6Gtš Go ÉGyōdGrÁ9GsuGzEGuÍ+GtRđGyĩŧGp`Gt‡Gr‚čG|ÎF†‰FĘ FÔ FÛn FåS‚Fė<^Fõ˜EF˙ĻGÖG ģ'Gŋ^Gb}G Ÿ¤G'žG/c‘G7 3GA8nGJ¤GUÃÉG^ČTGkxƒGu˛–GuyÕGqnÆGuĒũGt#´Gu(qGsĸGx¸@GlM.Gu%RGn.ÆGr˙•Gv&ņGqŨIGs.GqV–GnFöGqΚGuú?Gw)ÛGu|ËGsČŋGx~6GvœžGlbGyįGpLGp:GxâGu¤JGsÉGy0KGu×G|ąxGsßāGlé‚GrW1Ggā“G[;_GPQģGF„ˆG=ĸ\G5ˇ G,îļG%¯›Gé GįG”ôG 2G¨đFū’ąFõ~kFė“wFâéŖFÛ´ÆFŌFÉīKFŝFŧ]FļīoF­ÄF¨‘F]ÄjFfÁFlFu DF}[ũFF† Fˆ,&FŽ?F’5ĖF—FũÛFĄĘ´FŠFŦ3Fļ2ēFŊ‘ FÂČFĘōPFŅ[FŲ ŠFāsFęQXFķūG8ˇGč‹G :’G•?GLOG„jG%õG."áG5.āG>6‡GI^LGRįÚG\\_GioAGtŠ4GtÃ$GwŊŋGoŖGt“;Gqŧ0Gp OGs™¯GwŒGq¸öGrãÄGr)ßGtGqŅÃGuå=GuúķGsGs2(Gu˜‚Gq0’GwęĖGt‹-GnęYGtëŧGvXGukÛGqRÃGs™8GyEĄGs˜%Gq–”GqÄEGu=ˆGs‡¤GqļˇGrt¨Gkf?Gag`GVqFGLž‹GBģ?G9ų›G1;ŖG)õÜG!rĐGĶøGxG‰íG H GfĨFũÄāFķ…’Fę˙FFߙGF×FĐēĘFČą@FÁtëFšÎ¸F˛Ę'FŽgžFНF_Fgæ7Fm) Fu§F|î¨F‚$F…Y•F‰ŸæF%F”ŠņF˜CGF›RxFĸŠBFĻĐlF­‚úFŗíĸF¸~ŸG4ŖG.LG&?F]Ū’Fe šFn2IFrNgFyĢĢF€"ÍF…&Fˆ4ŋFF’†F–&ĸFšŒ-FĄ&hFĨ?ÖF­sF´ĨĩF¸=FĀ÷FĮjiF΀ĐFÖ(åFáPFįRČFņ+‚Fú6“G‹†G’öG ÆGœÍGJ‘G§6G'ĒĶG/ŖdG6ĐŠG?SėGIyFGRŸĢG\p\GgU0GqÛëGv2Gs„ĸGu‘ûGvã-Gp™§GsØ>GqĸvGsø}Gp>TGpJšGpü-Gq] Gs,MGu\CGsĸGxÕGpëMGsĪĮGpmÚGqfGyV…/G5€ÂG.|ŨG'ŽG„’GbÃG×tG tCG*ęGŖöFú‘FķŦíFé{FáÕđF֙FΈÅFÉ\:F Fš„RF´ŖDFއ‘FĒ$;FŖČ’F^ķüFgŠäFiå˙Fno}Fy yF€_F„.FˆRFFbF–šaF›ƒ+Fĸ FĨ—NFĒÃ˙FąsšFē“FŋFÄ5FĖū˜FԙFŪ–0Fåi†Fđļ(Føy#G)GuKG ĢGHGZåGŨ…G%kŌG+veG5o™G<ĀĘGCЛGOJGVG`<GkīGrÅCGpNæGt=lGtšĖGpĄŽGw*áGsęžGyŲcGvkčGrōųGzŋGr. GzkJGu ˙Gs/GshHGr[BGqKįGwVGwwJGuˆ!Gu@ÍGrķ­Gu%GuķGsĶ"GuPčGsFGt ‘GwLqGté×Go$“GeŽG]ÚJGS"äGJ`[GArĮG:?eG0ŊØG*ƒG$€ŸGąGG´uG äGԎF˙ÜJFøzFíĢxFäzôFŨ=ĪFÖsFĖšĻFÅ~ŽFŊ°ōF¸ąnF˛ ûFŽ#ĮFĻIFĄ`jF[Ô1Fa’ FiÛFqÎĘFxlČF€™ūFƒ%FˆÖ"FŒlŨFÛÂF•÷Fš˜ėFŸģF¤,øFŠsFąōFĩŅFŊųŸFÅú‹Fˤ`F͚^FÜKĄFâˇFFõf^F˙Ÿ_GnÖG 3_GØSGeeGĨ[G$?G*%0G1BG8†kG@ē˙GI˜øGRЎG[{ÆGeŲYGp*.Gs(čGt"‚Gvq+Gw¨ƒGuŖXGo×ßG|īqGpÅŊGoœ3Gsp]GtBGvëÉGt@úGuĄ#GuūG|„āGyÍ}GtrGt sGqŊGsPpGqFGnĐbGsÆGwUíGkˇ°GsâÖGw~öGr@ GqD=Gh{ôG^dšGTô‘GLĖßGE,G=yG6†ĢG/=´G(mŒG!´EGc/G$G&ËG qG7ÍGXFöę‘FėۏFæ„hFŨnĀFÖ§đFÎÆĸFĮƒ|FŋūSFēé’F˛‚FŽfFŠ–đFŖģßFŸI^FY ?Fc3éFiÂNFl´(FrúĀF|ŠF‚ŒF†(#F‹ĐŖFÕĀF•#ŊF˜ģČFFBFŖŸNF§ŲŽF­f|FŗMhFēÁ÷FÁęÔFČa FÎ^đF×M‰FßéPFéSFņ˙ŗFúŊČGÎüG°TG h“GíGחGšG$:¨G+.´G2UVG8ĀĪG@ķ.GH×ĮGQ!QGXéiGa-Gl_ØGuŊ–Gt͜GsxGxĄhGvÂûGrč+GsN‘Gtk™GwjGwr‘Gsã‚Gup$GqQEGqgGqZŽGo‰^Gup1GvkGv÷GyķėGtoėGvfGqP|Gu„īGv9–Gr€^GqŽGgpWG_ęGW1^GO fGG”˛G?ā}G7Ŧ­G2TdG*P˜G$ ˛GiĸGœčGŪėG ˛G?G ĄFú…đFķē!FérŸFáFRFÚ÷LFҟúFĖ”FíčFŋ–>Fļ5F˛-FĒø§FĨũ†Fĸ$“FšãFYpF_ÄPFf Fk´ŅFt™žFyˆUF5F…iFАFöÃF“Y(F˜ãFžpF ÁFĨÖĻFŦÃÚF˛tF¸ČFÁC–FĮ\ FÎnûFÕ jFŨ°ŊFáøĶFîÂFųÆFūƝGĩxG ŦŠGUüGíG9XG!^šG([ G-°ŅG4ų5G<˜GD“ĮGKCGT IG[ë[GbãGkfGqČãGu °GočGr>ōGtO…GrßŧGw€@Gp@&GtħGmļãGvą¤Gt˂Gp†˜GwGuō¯Gt`GqĒ™Gs_ĶGs‰×Gm šGzFÖGwÁōGp&€Gv(Gp‚GhNöG_'GV>GPãÚGHŗÕG@߇G:& G2˙†G+‡€G%/žGGė¯G†„GĄG uGž˜FūuFô1•FîœFåÄ FÜBÔF×yFĪŗFȏqFÁTŅFˇÕ0FĩíFŽdzF¨_ĩFĸ ĀFž~F™Ø2FX‹ëF_ˆFdļOFkFs0F|:xFlōF„€~FО*FŒJžF’ ßF–…F›­ F “iF¤ībFĒĪ;Fą|Fˇ´FžŠüFÅ\+FË[.FĶøšFÛF☸FéS>FôÔĮFû‚ĐGĨGjG …°G@@GYGČG%ZSG,&G1ÎZG7Ą|G=¯:GGW#GLMGU-ūG\É×Gd´Gjã,Gs1OGsSÉGr¸ūGqžGoÂŊGqø)Gu+0GrœGoÜŗGq€ËGvĢžGu­ûGxMŨGv”mGs78Gr#EGlÔnGx*´GpUDGo¤GsfŨGo Gnö‘GeábG\ЖGVÜ"GO:dGHi¯GB+ĐG9ļG4LEG-ŋJG&EG!GëŊG6ķG€õG ž6GsuGeFųđĮFņmōFę)QFá FÛ-ÂFŌbOFĖfŅFÅvôFŧČÎF¸¸ F°ēąFŦŧĖFĻ3ģF …•F›´F˜@ŽFX3ŦF]šÕFa§ŗFlDMFoظFy"F~#FƒWČF†ĐŠFОßFûJF–…Fš^FžO@Fϝ¯FĒģ/FąíFĩŅ{Fģ^ZFÃ\ŽFČÍÍFĐ×xFŲp@FāwmFį÷FņČæF÷€ĢG=VGų¸G č|GÛgGæ]G+XG!ąG&ČĒG-žîG3 ĩG9'ŦG>ĻôGGV–GM“5GV:[G]gČGb_Gk´GpčGw¨GtĀīGyûĒGt÷}GsНGvŽGt2ŋGwlGtÎōGu:´GyēHGs•žGq—Gy>Gt4*Gz”WGrCXGugŽGoÃGiŪ÷Gc…G]2GV×GMļTGF¨ũG@XŧG:KÂG4jâG.\G(ŨŊG!ėlGSąGgĢGö€G "G{ėG´īF˙ŗĐFô6xF몈FåFÜāÚFÖĸFÍbĩFĮXFĀ6FēŦ×FŗŦF°7˙F§•FŖˇCFŸĒnF™uF–ÃFUmNFZē Fd\ŅFiųÕFq´FuuĘFŌFƒĘXF‡‰F‰CFã^F“ÂpF˜`kFžHßFŖ´äF§ŠBF­˛ŽFĩÖDFēFĀęFĮzīF͍jFÕ`sFÜŠtFãųúFėŠÎFö#[FüįáGƒG¤OG ķG UGGØyG#‰G(ĶnG. ĘG5Ø G;ĒAGBRĸGHK‚GNQŅGTNūG\"+GaGg–#GnEGrˇRGs¤Gx 7Gs.šGpÅĪGyÍĪGrŠmGpļcGuDÖGr@Gq1ÛGq ÷Gsc‡GsĐ1GqЁGrÍGl,Gfé=G_ÔÕG[tfGSœīGLGG×GAũđG:G4tAG/¤ūG(;ZG"ؐGC\GÔÁGŗDG##Gĸ GŒF˙ƒFøXėFėû†FįĩļFŨKęFØNšFŅĐŽFÉ<FÃÞFŧ­Fļ<îFąÄ2FŦØFĻøFĄCqF7QF™tZF“2?FT_ĢF[’ĀFbaâFfĄ…Fk%ÕFvš!F} HF€ÖíF†-FŠEĒFžIF’2æF˜FFœCiFĸøF§% FĢ›`Fą3ĪFˇÔöFŋ= FÅE/FËFĶëFŲĸFßå\FéäFđRFøjGÆaGUĩG ؘGY€GŦ‚G¸ÁGųĖG$áfG*SčG/ëāG6]ĪG;PĒG@˜IGGĒĄGN;GRĩGY.oG[Ô%Gb˙xGdū‚GlibGn(oGpœ$GnŋGrNlGvG­GvZĶGxycGqvŗGt§GoüĒGnãÅGt‚oGsŒûGe¸Gaķ*G[sÆGV_¸GO/ØGIėģGC´ĻG@MG8f1G4ƒLG-ĸ'G(G"ĐŪG†G!GΑG}G 1îGŪ.G!ĨFú‰FōíÜFęįūFáWŠFÜÉFÖ ĖFĪĀFĮFƒFĀ`čFšœjF´'FŽ]ĘFĒ)>F¤šFŸ4:FšCF•‰F’PFR•vFZtĖF_›ÕFgõ FkŌFrēģF{KmF‚,›F„kŅFˆWIF!QF‘įįF–!1Fœ´FžƒąFĨm…FИöFąÅ Fļ¨äFŊ ũFŸĐFČ{OFĐqFÕŨrFŨÎrFä ÎFíøFöē0FũūčGCíG­^G ž(GōėG˙DGNG ÔRG$žWG+´ŦG0’ūG5;›G:…kG@ÆHGFŌķGISRGPÄ:GT´LGYr.G]&ÕGa>yGe@ŦGhũGhšÕGq>Gu¤JGr9/Gt(ęGqåGsVGwŸŧGx¸žGw´ōGlkŗG[LrGUĖGPذGM÷éGGŨęGA˞G=\G8A¯G1 ęG-ŒëG(WíG"bËG‚öGNRGėåGGG ö>GqG›Fû—}FōsĢFëŠFä§éFÜVģFÖĒöFĪąûFÉ}XFÄ3hFŧ-™FˇÆFąđ7FĢgFĻ;VF iŠFœ ˆF——‚F”Ũ†FŨ/FSŧŠF[ąģF_Ō‘Fc[ąFkôŗFsŌŠFzDņF€¨9FƒŽúF‡žšFŠ{ FŠČF”âNF˜ž8FĸdFŖ‚ĒF¨ū_FŽzFĩíķFšA§Fŋ@áFÅĐZFĖ]+FÔ¤˛FØÅŲFâfÄFčņŪFņ LFú÷ŋGVŲGƒG øÜG NčGšOGfG^G!8˙G%ÜëG*ÍrG0JyG5ZG9w5G?Ĩ%GCEŖGGĮGL „GOÜāGSxaG9{kG=ŋXG@\æGD˛ŸGFŗ§GG“ZGJh~GMJ'GLŨGMęGyNôGo(GuŪ˛GuûCGr×IGrŠúGDËVGA<ØGAhG:ũÎG8(ŪG4¸ÍG/Ņ-G,~`G'*[G#ãĮGö¸Gë‘G‚ŅG-šGqG FæGšGÆÖG‰F÷č˜FđĮÍFë ˛FßĮZFÜ=šFÔ~ŦFÎļŊFȊrFÄ;ĘFŊŨFˇĒĸF˛r†FŦøŒF¨ĐBFĸ5LFŸFš‡ąF”ųčFīŒFĸpF‰FLËFU'ĸFXZûF]ŋ?FböHFk*yFq3™FzúSF}8—F„q€FˆNņFŒNēFDîF‘ÍžF™Ã8FĢĩF¤gFĻ„AFŦ{ļFąˇŽFˇlëFŋāØFÅzĶFĘĒAFĪÉF׊ņFß~ąFäŸJFęđ€Fô]FúRõGGhúGŋíG MGˆ7G.ŽG%ĖGėG ęzG$Ø G(ëžG-G/ {F“`ŲFŒåĖFЎûF†ã–FLņFOÜÄFY‡9F^Z4FcÜZFk”ãFoĩ Fv›íF~5’F‚ąĘF†ęFŠHmFŽUÔF‘’ÍF—ėLFšÆEFĄeF¤°2FŠčF¯ũ'F´ƒąFŧ-FÁ‹åFÅíõF͈RFÔŲÎFŲ‰ FāĐSFåû?FęjFôRVFûĀG,âGlhG !IG ’RGķËGžkG÷”GĒLGí|G"n%G'9œG)o;G-­ôG/°ˆG2üG5ę1G6ĩČG8„eG:ʃG:ĒDG<ōĘG<’:GŦFĸŠĒFqF—9,F“=ĻFsÉFRAF‰'ŗF† \FLĄMFQÎ:FTĩæFZļoFa”>Fh€‘Fn4ãFtŊ*F{F€˜YF„TÆF‰ÜFŒˆdFš§F•āF™ŽPFÕ™F¤^gF¨M–FŦ,íFąĻFˇžĻFŋéFÂÍZFËJ3FĪĪŠFÕpFÜPÄFãnŗFéMFņ6F÷ŸFũāGUßGUG yąG ‚"GZœGŖņG ¨GÜ!GÜG!ØÂG$ÛgG&X˙G(h G+­G-ôOG0‡qG0ØXG3ĪG4bāG4FG4mŸG5ApG5g G[øēGpōūG0ˆĸG.fÍG-aRG+ÅG(Á)G%™īG"ĸUGmÔGË!G€žG/G¸„GG  GÍ~GøMGgJFũšFøFōÚFéũUFåCFŪš?FŲŧ0FĐ8åFĖvšFƋÖFĀVFģtÃFļØŋFą•VF­sF¨eĨF íFŸ"F™UF•~F‘¯FŽFŠÅ8F‡dFƒîWFG`åFNŌfFT+ŧFX'0F_ÜÚFekFmęJFp‰bFwN‡F}ĪŖFƒF†ĨOFŠ—&F3'F”~F–FšōƒFŸzæF¤´bFŦ F¯ŽFĩ:ZFē2FĀūFĮnoFˏQFŅ8FÖÉxFŪ™-Få§ĀFë'FōļFøOFüģ4GŠGæ[G˙G +æGcJGGŽÁG7JG>ĮGģBG!ž G"øŧG%}G&ŸG(ü1G*ßëG+jG+QĐG,*G,=$G,׉G+øG,OåG*´ëG)‡ŽG(j~G%#ŸG#ŨäG#_ŨGN\Gu1Gú:GpÂGDbGåČG­G ‰fG“#GGGĪFūWŸFõrīFđŠ Fę’FãGņFŪ>F×ũĸFĶšFĘ÷CFĮiĸFÃ7ļFž^xFˇ­ĨFąČ…F­nF¨HaFĸșF NAFœ˜äF–žF’@ FGF‹ŧF‰/IF„‹ĮFQxFHāFLģFPŽ FVĻF[œFc>wFičøFnuĩFxŦFzëˆF‚dDF„čyF‰‘FŽYĒF‘ˆ•F–Q:F›vĩFžhîFŖúsF§hiF­á(Fą˙9FˇrĖFž —F ŖFȸ0FÎV„FŌ¤šFØN(FāDŗFå!âFíFņËÛF÷čF˙†-G7=GíãG%ēG ƒeGÜžGdįGQ˛G™“GxGc~GC G oõG ™€G"G#éĪG#úmG$b­G%ǎG%å“G%‡{G&zŋG%ã¨G#PG#€G"h^G ŌUGˇG_:G*ÆG˙UGÛõG:GŠéG Á˜G +jGeTGø‹GMÂFû5PFôÛģFīénFęřFâ›ãFßG†F×vâFŌ„uFÎĨ+FČëōFšcFŧ˙Fļ1=FŗáÄF¯iĒFŠŗFĨŋIF Ė[FœŽōF˜ĢF”aFOōF‹ŖFˆ;’F†$æFgF}÷ŲFJ4ĩFIōĖFO"FTÔâFX|qF_ķÃFeŨđFmŅFrĄFw ĄF~KÁF…?ĀF†ĸëF‹ūžFJF‘øŒF˜TžF›Ķ>FŸ(vFĨžFĒ. FŽÍF´ŋÎFšBMFž:öFÄõmFȖ FÎėÎFĶŪĮF܍FáP Fæ˛îFęFsFņSÄF÷/éFũ}Gg_GAZGf.G G ÚÆGáhGHėGŨG^KGJGŊ¯GųGå›GWķG"įGęGÎŌG ØŋG‚xG VG˜€G6§G×ĨGcĸGˇZGXG3…GōG.GŸmG \†G E–G\ûGøGūuFūõF÷´MFõēFíųŖFęÂĀFáėFŨ´wF× ÄFŌÛõF͘æFÆŪFÃ9Fģ ĮFļ°ˇFŗÂ!F­>%FĢ|nFĻ÷FĄ–˜F^ŪF˜į0F—”§F‘:äF­†FŠ‹ĘF†đžFƒŲ’F€ŦšFzú_FD8…FJSÄFM˛FPTîFYy9F_ƨFeJŖFk†FĐ]F•×7F™Q"FKŧFĸ4ĀF§p,FŦf/F¯‚œFŗ÷9FˇņšFŋģ FÁė|FÆãeFÍģ FĐFÕAFÛ%FáŗFäSFé™.FîÍoFķĻĶFø.¤Fũ,ÃGæįGīuGĐ`G"ŊGƒBG Å`G OG hG ^ÜG ŨũG á†GÃ0Gå‰GļôGSäG ų+G KņG XĘG :qG >ĀG‚ Gķ Gt:GöGëąGvÍFüU™Fø#gFõŒ8Fņ‘{Fë˙2F掉FâCÕFŨĀSF×-FÔxÛFÎëŗFĘõįFÆÕ$FĀ ŧFŊ*CF¸ žFŗ_lF° ëFŦŒšF§˛{FĸėFŸfëF›jÍF™ ŠF”¸F_°˛FeųXFkĖäFpĘ*FxÂF€0=F‚˙ØF†čF‹šF0ŽF’ž~F–€.FšjŒFžÕF¤3F¨lFŦ F¯G–F´ĸüF¸CFŊגFÂ4ĶFƕFÍ{~FĐ~VFÖ ]FÛĀŧFßá›FåĢ-FčÖĖFí;Fō 'FõĢwFú_aFũÖŠG ŅGūGâGđĘGK`G˙ŧGũBGá#G 3üG ŅÃG B G÷KG Z˜G gYGã:G+ČGcG<ÎGÔŽGĸGÍF˙”FûâŗFövĐFõĄĻFōĨ;FíÅoFęN˙FäšYFß3—FŨBķFŲ FÔFÍ6bFĘž|FÆ]FÁ.ŅFģËaFˇiF´hF¯4BFĒ'FĻ^åFĸÜ$FŸŠ2F›˙ŌF–œF“‘>F‡zFŒ FŠØOF‡jF„v|FŊmFy^FtlĶFošFj;F>UYF@mÄFDĖīFK9“FL+nFSFX|žF^ÉxFc[ĘFjŅ,FnžTFx/×F~ëF‚ îF…¸÷F‰XíFŒüĻFäôF”1ũF™BFZF G2FϏĸFŠčkFŽŽįF°Ģ Fĩ¸ōFēģßFž6ĶFÄ"5FɨFËÜfFŅušFԍFÚ?ßFßL4FájFčZņFęâéFîˉFōÅHFöGošGöGˆGŌŖGĒnGˇ.GüËGÛtGĖ&GæäGųGū/G—‚Fū'Fû%‹FøŖXFõIËFō:FīvFëŅ Fč˜FäAQFáåęFÚéjF×%!FĶ ŧFŅ*FĖĩ¸FĮĪUFÃ×kFĀFēæMF´ģbFąėF¯4FŠŗåFˆ'ąFƒ#$F‚+/F|özFxV:FqĶ"Flõ“FdéRFeļF7ãrF¸FeėF]9•F7ĒOF=F@+FB&VFGŅFJš˙FRžFUĒbFYĄÃF`4FeddFjŪÔFs"ƒFvŠôFzEjFƒ+ōF„^CF‡'ĐFŒ‚„F!F“${F—%­F™DŨF’FŖRQFĨÔ F¨Ņ¯FŽĐčFątCFĩÖĩF¸E5FŊĪeFÁËFÆČšFĘPņFÎk$FĐ~RFԝčFØjFÚÕFFßFۈAFäp´Fäô,FæÖÚFčk˜Fí ¨Fė­$FíHūFīēāFíåŊFîŖŸFîÍqFîÚWFī…Fîģ,FęŨVFëyËFęIáF菨FåõWFã~ĒFāŧŦF܂uFڀF×ģWFÔ<ŦFŌ“FĐc:FĖPdFÅĢAFÆ@zFÁbFž ĀFēŠFĩ‹]F˛k?F°{ûFŦsüF¨Š)FĨ2ÄFĸ™GFŸ]ęF›ždF—2ŋF”)PFIņFŽAFЉFˆ¤đF…’•F‚zF}ķ—FwmZFsΖFnøđFj:†FdęF]ЙFXŦ3F5î4F:4F<*zF@ŗKFG$F‰i;F‹öĮFŒČđF‘¯ĘF”0)F˜Fœ-QFŸāF \‘F¨õFĒ1ŌF­ĨF°ÎōF´uœFˇ-8FēTŋFŋ!ŅFÂĀFÄ­5FČXŒFĘÚŗFͯzFŅd6F͇*FÕČ,FØō“FŲō=Fۏ~FŨXF߉FۘŦF߯ØFŨŗFŪ&ķFߐFßÛæFāOãFŨĶHFÜSøFÛ~ŅFږF×Ģ_FÖ¸÷FŌŦFŅģūFĪpØFË­ĒFĘN¨FČŽFÆVĮFÃüFŋÜÂFžeÉFšĻ÷Fļ‘›F˛ŖSF¯ …FŽ8ŗFĢmËFĨ]„F¤FŸĶĮFĨîF™ōĘF˜,F’ôFonFéF‹ F‡>^F…ËęFĮF~ˆFxđdFpךFnč'FkՕFfÔF`åņF^(ĀFUŽ(FUsÃF1ņ’F4]ūF9ōF;­FA‡^FCОFNā–FP,OFTļpFZđF^ųFd.FhŸÔFl–ģFrrBFuE[F€ĖF€˙€F…é'FˆWųFІ0FĐúF‘¨F•O=F˜ }F›ŋųFŸĒiFĄ;FĨ›FŠë;FĒŖÚFŽÚ"F˛cģFļÔoFš"įFŧIFŋĘ[FÂdŖFÆ.FČiFÉäÃFĘbˆFФBFĪå4ĄF@ÃFJˇĩFM]ĢFQBÔFXfæF^ō„FcvČFeLõFeQ´FoFsÜĶFwߖF|ƆF‚ F„Ė„F‡žĐF‰€›FŽßF@‚F•ËœF—€lFšĶíFá$FĄĩ FĨ­ĩF¨ŋ›FĒŽF­ôÍFą§ĮFĩ3ąFˇkÁFē2•FŊFĀ wFÂĻüFƑŦFĮ¸]FÉXËFĖŅcFĖ€‹FÍ}ÂFÎô˛FĪ”bFŌTôFĐ)FŅßJFĐāDFŅÃjFĐoFĪøÍFΉčFĪ|ŠFÍØžFĖårFĘŠFČoÜFÉĸ§FÅ5ÅFÁĐ FÁ HFŧLÕFŊE0Fē>fFļŅüFŗĖF°ęáFŽøxF¨üF§ŗF¤˛šFŖ ōF 6áFœíF™ĒÚF˜&÷F”tFKFqœFŠ6%FˆöØF‡Š­F…ņÕF‚Ų2F||F{” Frŧ?FmA¯FirŪFeIbFaĐÔF_ dFUÍ1FU!FRvFJúÔF)đtF.°ņF1¤HF: ˆF;­đF<^”FDrLFJŋ5FQĮ)FX?ˆFXnAF\*VFb _Ff‹FißFm7ŖFv˜œFzsEF}ŅeF‚AeFƒŋĄFˆZĖF‹ŨŽFjF‘ĪģF“ŪˇF˜h-F™ƒFÛFĄĖaF¤€FŠŌ¯F¨˛œFŦžãF¯ąĻF˛Õ(Fĩ4ņF¸JUFša=FŊ˜QFžPFˆFÂq‘FÄf FÆ`üFĮŽeFȀŅFË[FÉčuFÉ FËĀžF˓nFËĻ3FÉįīFʨäFĘ˙9FĮĢFĮ,’FÄÎWFÂĐFÂqFĀķ?Fŋ–ØFŧ‚¤Fē?FˇfhF´ÆvFąÕ­FądF­ÄeFĒČëF¨8iF¨|ŽFĸk†FĄ‹éFŸ*FœL‘F—~:F–HįF“äßFŨ)FŽÔPF‹nKF‰$ãF…æŒFƒƒíFÜâF}"ŠFyĮNFt `FoyÂFh\FeÄ8F`-F\:ÂF\^FT€FQÖVFJĸ FJ1 F(ĘHF-Â÷F2b“F4ŧQF7F?xFA"ŽFIĩFMú–FSĄĐFW?FYjF^í1Fc˛FdĨˆFl‡•Fm YFtžFzMaF~č´F‚͡F…,F‰fÖFŠæjFĘČF´úF”cšF–>'F™€’FœÁ€F _ŖFĄëĖFĻÅHF¨ĐnFĒFŽ(čFŽæ’F˛Û¤F´‹\FļWFš}FģFž>FžwzFĀÅŊFÂĘ4FјFÄrœFÃnkFÄo1FÄC6FÂØ˙FÂe¯FÄz8FÂ%ķFĀí¨FÃ+ŧFŋŦÚFŊĮFžĖKFēģĄFšžqFšGFĩVūFŗ1%FąŌF°,¨F­bĀFŦĶ’FŠe'FĻąjFĨqŨFĄļbFžx˙F6ĸF˜ī—F—†ļF”‹āF“÷.F’StFŒQĸFŠą:Fˆŗ¨F†¨`F…XkF}ũ*F{4xFxëfFtx°FnQ“Fiu-FfŽÃF`•ķF]đ@FY, FVeöFPĢąFL‹FEĀ\FCÁF(’õF+'\F0‡KF37F5_JF9Ō¤F?č]FE6pFL FN;ZFS!FVŪF[-PF`>F^§,Fd‘XFk‘CFroXFu0Fx‚ĢFvĢFj9F…rBF‡ņvFŠōJFüŦF‘NĄF”ëF•–/F— F0ŒF"]FĄyįF¤*ėFĻÔDF¨´FĒ´ĪF¯Ā F° Fŗ24F˛9jFļ7čF¸ĪFēÅFē›ãFģŅzFšVsFģŊ]Fž…0Fž_…FŊ‰ØFžG>FŊOjFģë Fģ' FģnšFģUF¸—FˇzĖF¸°8Fĩ<ūF˛¨ÔFŗ'öFąåßF­ßzF­Ĩ{FŠĀ–F¨ë"F§žēFĨšÅFŖ*EF û:F8ôFšj§F™F…F•ųÃF“ĶFŲ FęF‹ūĀFŠæôF†KōF„IˇF‚R=F.×F˙aFxˇFsČFmđ0FkŲ%Ff sFaûVF]&5FY+ĀFTyÜFQ āFL¯vFK@]FC&ŒF@ō›F%ąčF)wGF-7hF0á˜F4ĩœF91õF:‡pFCoéFDŖFIčũFL?ųFQeFV ¯FW&ėF^!¨F_áęFgsĮFj Fo=qFt…ôF{ üF~ˆF‚ ÃF„œÂF†aõFŠŠMFŒĪF †F†5F•‚F˜wyFšeFnœFŸ„ŨFĄŅŽFĨFĨžÅF¨ņīF&J^F(F)F/@uF1čöF8<0F9_@F=‰éFA‰.FGFKSĖFMU×FP›MFSˇ,FYVuF\ĄFb$ĒFf?öFkŒ{Fm“ÕFuuŊFyî‡F{ĶUF‚dŅF…Fˆ‚—F‰GZFŒēĀFōF‘ŖcF“dČF–Ŋ…F—īÉF›ŦuFĶôFŸ¤sFĄ3ÕFĨČFĨXFĻaF¨˛ūFĢÎvFŦŠFŦšFŽæ’F°˙ŌF°|ŅF¯)ŠFąËF°ČįFą˛ģFą" F˛CčF¯*F°PNFާ]FŽõF­ÜFŦŒéFĢŖFĒđF¨ŠF§:FĨW—FŖ˜FĄĮ$FŸ0ZFŨFœņ=FšĄF—R(F—˛ƒF•˜F‘ĩáF‘FWF‹ŪēF‰ÃnF†įgF†ëF‚ãF‚ ĖFŖFw Fs~ßFnšFl”ÕFj/“FhŠNFbuļF\RFYwXFYŖíFQÔDFL|sFI[ÕFDFCJĢF>bņF<9eF!F&zF(ĖF*ž”F.B’F4^F7v˛F9rhF=ĐbFAj[FDŲ¨FGô>FJ’rFN<FQ›FT×ÁF]§ÔFa}FfQ‘Fj9IFlí.Ft FwãžF|ĢF€@SF„‚’F†ÜĶFˆļF‹ˆFHūFŽ”˜F“)F“8ĖF•O(F˜ũIFš›F:ÍFŸÉĮF !ËFŖĸAFŖCáFĨ%qFϤFĒ{bFĒnFĢžûFĒ`üFĢF̞đFĒėGFĢųFSFœéâF›ĖōF›t™FšŸēF˜?F•áQF”õmF“Ū2F”ęžFbžF§FŽzgF‹^åF‰YĒFˆ+ Fˆ,sFƒėTFƒŒF‚áF}goF~ĩ¯FxÅFsؚFon¤FluaFh¨EFf`ƒF`}#F]äXFZkFFXZFU.AFPÔzFMÛįFNaũFG„iFDŽxF>~F;ÛxF7ŦéF7F2ĄČFuĨF!;ÛF!‚uF$ô”F(īeF*ÚâF//ģF0\yF5é8F7ŊŊF;¤F>–FAÅtFEæFJwcFMVFQÎ5FRāXFYâĄF_ßF_ FažŊFiq;FkģFo79FtmÚFxiFF|aTF€š;FƒoF„ēaF…ŠųFˆ›áF‡đUFŒ¸ĻFŽ(FîF‘˙NF’ŪâF“š•F”÷¨F—ÉÎF˜F™|†F—Ú*FšrTFœĪÜFšßĖFœ‘ Fœ?žF›9+Fœy-Fœ!3FœAF›áTF™nVF˜ęZF™‘2F—ā˜F–ŪjF•ˆÜF”x F•PŲF’ęÄF’FíFŽyFoąFŒĘFŠzœF‰]ŲF‡„#F†)ĮF„áąF‚Ã4F€Ä—F}ČFFzZ]FxaJFt}2Fp¸SFn™ÉFgu(FfļZFcŽFdDF\!ôFZ;ķFTž)FRzFP—FMs>FI3FEÚŲFD1ĩF@KôF9FF8,F4ØtF5īF/Ķ-F žFąŸF ’-F"ėF&oßF,7íF+x F0‡yF0+5F3ˆ7F9\ÔF<âF=äFAīeFC×{FKÆFJ} FMá FU߁FWšFYÆFaqĮFb˙zFg›šFh؟Fm–#FrˇFtņŧF}AF~(yF€1×F‚ÔŠF„‹KF†CFˆņeFˆāF‹ŽĸFœķFûF‚ F‘‡ņF’ÃF’æŽF“ˆF•ŽFF–Á/F•ˇ6F—*xF–SėF—uŽF–‚ÔF—¯?F•M™F–—ÁF–CF•@ëF”˨F’žF’îbF’–fF’}¤FėWFôF&tF'ÜF‹XFŠ0˜FˆōĮF‰gF†Ņ?F„x FĪņF€ņ†F€äFx™9FwDŠFuLJFvVšFm5ŋFkō˙Ff´Ff•FaÁ2F^ä5F\OFWĄõFUų%FPHFKVņFJ FHĪTFE2ãF?ø˜F?ŒyF;ūWF8AŗF2sĖF1ĢF/†F+ĀFųĻF+˜FËFTáF"´9F'ø´F*Ģ[F,#ąF/yF1åPF5CÁF7ZF:F={mFC$FE‡0FG9FKĒŠFNV¤FQMŖFUЍFWŒúF_ėF`õäFdɉFfėãFmEFqAFrö”Fw…ÃF|ĒF×ÅF€œöFƒÉ8F„rF…ÎF……ŠF‰ 3F‰ā“FŒPmF‹÷ FįFāDF‡ęF”RFtlF‘F‘ŖdF“Ø˛F‘ūpF‘ÁÁF‘§QF’ĨF‘õ‰F‘áHF>Fޞ^FöōFŽĀFŽ@FŲāF‹k`FŠB.FŠ FŠQF‡F†Ļ2FƒäFƒ„FF€ĐFž,F|ôFu˙ÁFvŲFsü!Fq Fm˜ÎFg*FgįrFaŌFcFjF\RKFX *FU‚āFS%!FR´RFMs‰FH*0FIŊƒFB:FB>F=“F:°ũF8,F3\F2ĻøF0]F,éqF'ĶĢFā'FFņFnĀF>F!6˛F"jšF&ÃF*‰ņF-ûF/F„]ÄF„ÜđF†ÉÚFˆ;F‡Â„FˆúöF‰(ōF‰LˆFŠ;tF‰æLFŠĀF‰ØĮF‰/âFˆ?šF‰NIF‰{F†˛đF…ė,F…ƒåF…ßėF„ŗ4Fƒ|xFē F~įYF€„˛F~ëæFzvXFvLĮFw0íFsūæFoá_FoÍ5FlæFi„YFdžÃFaõ>FaœF[‰ŨF[3\FW‹FWÚ>FRlōFM8ĢFO\ŨFMøFF4ˆFDÉëFBė–F>°čF=¨øF:4úF5E‚F1 F.?qF/‚¨F+JDF'ŒĻF'[ F ėČFŲ§FņFu/Fx€FsqFŪ.F!l[F$)ŧF)LF)eŅF-ŋ F/ŊSF2@F5ĻŅF:=ÖF;N;F=ģF?æĸFC<ŸFD^FFHņDFMÃFO>=FSÂöFQØFY[,F[,•F^áôFdpuFfÂĐFg*‚Fkđ>FmmēFnaYFrEFu!ģFx{ F{Ē‹F.ÍF€>ŠF‚€‹F‚7īF€üFƒ 9F„ûFƒ*>FƒîF†˜sF„´˜F…ą•F„rF…›ÎF„ŪF…ZĻFƒ›rF„ ÉF‚åˇFƒË.FĢsFLFøF|īLF~^öFyP9Fxå}FwÆuFv„`FrvžFqĩîFtŖ*Fm9ŖFkÄéFlČ)FgZFbXFFaÕOF^ĨŧF[ęFUJFUČÚFSn"FQ*FMæąFKĪ:FIœFF2FEÁ˛FA)nF;ŖÄF:rF5ŸųF7N­F4ėÉF1œF/ŧF)x—F(­F%F$[JFęã././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/tests/data/synth_highsnr_table.fits0000644000175100001660000007020014755160622026045 0ustar00runnerdockerSIMPLE = T / file does conform to FITS standard BITPIX = 16 / number of bits per data pixel NAXIS = 0 / number of data axes EXTEND = T / FITS dataset may contain extensions COMMENT FITS (Flexible Image Transport System) format is defined in 'AstronomyCOMMENT and Astrophysics', volume 376, page 359; bibcode: 2001A&A...376..359H ORIGIN = 'STScI-STSDAS/TABLES' / Tables version 2002-02-22 FILENAME= 'synth_highsnr_table.fits' / name of file NEXTEND = 1 / number of extensions in file END XTENSION= 'BINTABLE' / binary table extension BITPIX = 8 / 8-bit bytes NAXIS = 2 / 2-dimensional binary table NAXIS1 = 160 / width of table in bytes NAXIS2 = 69 PCOUNT = 0 / size of special data area GCOUNT = 1 / one data group (required keyword) TFIELDS = 40 TTYPE1 = 'SMA ' / label for field 1 TFORM1 = '1E ' / data format of field: 4-byte REAL TUNIT1 = 'pixel ' / physical unit of field TTYPE2 = 'INTENS ' / label for field 2 TFORM2 = '1E ' / data format of field: 4-byte REAL TTYPE3 = 'INT_ERR ' / label for field 3 TFORM3 = '1E ' / data format of field: 4-byte REAL TTYPE4 = 'PIX_VAR ' / label for field 4 TFORM4 = '1E ' / data format of field: 4-byte REAL TTYPE5 = 'RMS ' / label for field 5 TFORM5 = '1E ' / data format of field: 4-byte REAL TTYPE6 = 'ELLIP ' / label for field 6 TFORM6 = '1E ' / data format of field: 4-byte REAL TTYPE7 = 'ELLIP_ERR' / label for field 7 TFORM7 = '1E ' / data format of field: 4-byte REAL TTYPE8 = 'PA ' / label for field 8 TFORM8 = '1E ' / data format of field: 4-byte REAL TUNIT8 = 'deg ' / physical unit of field TTYPE9 = 'PA_ERR ' / label for field 9 TFORM9 = '1E ' / data format of field: 4-byte REAL TUNIT9 = 'deg ' / physical unit of field TTYPE10 = 'X0 ' / label for field 10 TFORM10 = '1E ' / data format of field: 4-byte REAL TUNIT10 = 'pixel ' / physical unit of field TTYPE11 = 'X0_ERR ' / label for field 11 TFORM11 = '1E ' / data format of field: 4-byte REAL TUNIT11 = 'pixel ' / physical unit of field TTYPE12 = 'Y0 ' / label for field 12 TFORM12 = '1E ' / data format of field: 4-byte REAL TUNIT12 = 'pixel ' / physical unit of field TTYPE13 = 'Y0_ERR ' / label for field 13 TFORM13 = '1E ' / data format of field: 4-byte REAL TUNIT13 = 'pixel ' / physical unit of field TTYPE14 = 'GRAD ' / label for field 14 TFORM14 = '1E ' / data format of field: 4-byte REAL TTYPE15 = 'GRAD_ERR' / label for field 15 TFORM15 = '1E ' / data format of field: 4-byte REAL TTYPE16 = 'GRAD_R_ERR' / label for field 16 TFORM16 = '1E ' / data format of field: 4-byte REAL TTYPE17 = 'RSMA ' / label for field 17 TFORM17 = '1E ' / data format of field: 4-byte REAL TUNIT17 = 'pix(1/4)' / physical unit of field TTYPE18 = 'MAG ' / label for field 18 TFORM18 = '1E ' / data format of field: 4-byte REAL TTYPE19 = 'MAG_LERR' / label for field 19 TFORM19 = '1E ' / data format of field: 4-byte REAL TTYPE20 = 'MAG_UERR' / label for field 20 TFORM20 = '1E ' / data format of field: 4-byte REAL TTYPE21 = 'TFLUX_E ' / label for field 21 TFORM21 = '1E ' / data format of field: 4-byte REAL TTYPE22 = 'TFLUX_C ' / label for field 22 TFORM22 = '1E ' / data format of field: 4-byte REAL TTYPE23 = 'TMAG_E ' / label for field 23 TFORM23 = '1E ' / data format of field: 4-byte REAL TTYPE24 = 'TMAG_C ' / label for field 24 TFORM24 = '1E ' / data format of field: 4-byte REAL TTYPE25 = 'NPIX_E ' / label for field 25 TFORM25 = '1J ' / data format of field: 4-byte INTEGER TTYPE26 = 'NPIX_C ' / label for field 26 TFORM26 = '1J ' / data format of field: 4-byte INTEGER TTYPE27 = 'A3 ' / label for field 27 TFORM27 = '1E ' / data format of field: 4-byte REAL TTYPE28 = 'A3_ERR ' / label for field 28 TFORM28 = '1E ' / data format of field: 4-byte REAL TTYPE29 = 'B3 ' / label for field 29 TFORM29 = '1E ' / data format of field: 4-byte REAL TTYPE30 = 'B3_ERR ' / label for field 30 TFORM30 = '1E ' / data format of field: 4-byte REAL TTYPE31 = 'A4 ' / label for field 31 TFORM31 = '1E ' / data format of field: 4-byte REAL TTYPE32 = 'A4_ERR ' / label for field 32 TFORM32 = '1E ' / data format of field: 4-byte REAL TTYPE33 = 'B4 ' / label for field 33 TFORM33 = '1E ' / data format of field: 4-byte REAL TTYPE34 = 'B4_ERR ' / label for field 34 TFORM34 = '1E ' / data format of field: 4-byte REAL TTYPE35 = 'NDATA ' / label for field 35 TFORM35 = '1J ' / data format of field: 4-byte INTEGER TTYPE36 = 'NFLAG ' / label for field 36 TFORM36 = '1J ' / data format of field: 4-byte INTEGER TTYPE37 = 'NITER ' / label for field 37 TFORM37 = '1J ' / data format of field: 4-byte INTEGER TTYPE38 = 'STOP ' / label for field 38 TFORM38 = '1J ' / data format of field: 4-byte INTEGER TTYPE39 = 'A_BIG ' / label for field 39 TFORM39 = '1E ' / data format of field: 4-byte REAL TTYPE40 = 'SAREA ' / label for field 40 TFORM40 = '1E ' / data format of field: 4-byte REAL TUNIT40 = 'pixel ' / physical unit of field TDISP1 = 'F7.2 ' / display format TDISP2 = 'G10.3 ' / display format TDISP3 = 'G10.3 ' / display format TDISP4 = 'G9.3 ' / display format TDISP5 = 'G9.3 ' / display format TDISP6 = 'F6.4 ' / display format TDISP7 = 'F6.4 ' / display format TDISP8 = 'F6.2 ' / display format TDISP9 = 'F6.2 ' / display format TDISP10 = 'F7.2 ' / display format TDISP11 = 'F6.2 ' / display format TDISP12 = 'F7.2 ' / display format TDISP13 = 'F6.2 ' / display format TDISP14 = 'G8.3 ' / display format TDISP15 = 'G6.3 ' / display format TDISP16 = 'G6.3 ' / display format TDISP17 = 'F7.5 ' / display format TDISP18 = 'G7.3 ' / display format TDISP19 = 'G7.3 ' / display format TDISP20 = 'G7.3 ' / display format TDISP21 = 'G12.5 ' / display format TDISP22 = 'G12.5 ' / display format TDISP23 = 'G7.3 ' / display format TDISP24 = 'G7.3 ' / display format TDISP25 = 'I6 ' / display format TNULL25 = -2147483647 / undefined value for column TDISP26 = 'I6 ' / display format TNULL26 = -2147483647 / undefined value for column TDISP27 = 'G9.3 ' / display format TDISP28 = 'G7.3 ' / display format TDISP29 = 'G9.3 ' / display format TDISP30 = 'G7.3 ' / display format TDISP31 = 'G9.3 ' / display format TDISP32 = 'G7.3 ' / display format TDISP33 = 'G9.3 ' / display format TDISP34 = 'G7.3 ' / display format TDISP35 = 'I5 ' / display format TNULL35 = -2147483647 / undefined value for column TDISP36 = 'I5 ' / display format TNULL36 = -2147483647 / undefined value for column TDISP37 = 'I3 ' / display format TNULL37 = -2147483647 / undefined value for column TDISP38 = 'I2 ' / display format TNULL38 = -2147483647 / undefined value for column TDISP39 = 'G9.3 ' / display format TDISP40 = 'F5.1 ' / display format IMAGE = 'synth_highsnr.fits' END F’*˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙C€˙˙˙˙˙C€~|˙˙˙˙Ãv˙˙˙˙˙˙˙˙ÁÅ+˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙?__F‘dA’ļ{BģÜB„>Â>“û3>)ÕbÂ> Ažũ6C€˙=]UžC€~|=WŸcÄĸQD Xd?‰ŠŌ?Y~`Á‡;'; áĸF’*F’*ÁÅ+ÁÅ+ŧÖŲ=ä%.ģŽŊ=ÔnFŊ•wˇ=§ÔDž ŊŒ>­Aō =Fâ:@?ĩĩF%ĒAą[HBâBŸŨ÷>“û3>)ÂÂ=ûpAžëC€€=sZĻC€~S=mËÄ ˆžD?‰ŽI?^ŧüÁz;,a×;+˙/F’*F’*ÁÅ+ÁÅ+ŧ×P÷=ä§ģŽQD=ÔSĀŊ•=ŗ=§uđž Ŧļ>­ . =´dö@?!azFŖdAÖ§¸CĐüBÁ|Ī>“û3>)žļÂ>ĀAžįūC€€=…ÖŨC€~(=‚ažÄ˛D'V]?‰’/?dųÁjŠ;QvA;PŌįF’*F’*ÁÅ+ÁÅ+ŧ× Ģ=äˆģ)ü=ÔO'Ŋ•ˆ|=§ÃÍž ¨ƒ>­ _ =Ķ9'@?1„ĶFĩBØ!C%…Bę>“û3>)Ā,Â=ū­ S =@?CEOF GB'CHTžC §Ę>“û3>)ĘjÂ>ûAžōōC€ū=ĄüŨC€}Č=ÎQÄ6Á@;šė;šBŖF’*F’*ÁÅ+ÁÅ+ŧÕÕd=äģ =Ô_•Ŋ•}Č=§Į¤ž ŗœ>­$Ž =Ūm.@?VĖ>F `B>&Crd‰C+eÅ>“û3>)ÆÖÂ=ųvAžīšC€€=˛*NC€}Œ=­”?ÄO6YD^Ŋ‰?‰—’?uÁ#‹;ŧÆ;ģŊĨF’*F’*ÁÅ+ÁÅ+ŧ׃=ä!ģ­š =ü™š@?lGF HÎBfŪC’¤ĪCObš>“û3>)ÅĐÂ=ũpAžîĨC€€=Ãû#C€}N=žíŗÄcîXDuO?‰—.?zë’ÁØ;æM;äĶÂF’*F’*ÁÅ+ÁÅ+ŧÖö=äˇģŽ@=ÔXŪŊ•Z =§iž ¯ú>­Đ >&•ˇ@?ķãF ÷B‹6îCąvįCzø˙>“û3>)ȆÂ>Ažņ.C€ũ=ךC€} =ŌdÄz˜D†Įi?‰™}?€|CÁփ< É< ¯ôF’*G5ö´ÁÅ+Á:ēŸŧ՝=ä ģŒ“=Ô\ÎŊ•ŧM=¨Qž ¯ë>­Ž >‰“č@?Žō­FX÷B¨Č÷C×(×C˜$ >“Ką=ë7éÂ=eA] C€õ=ŖđC€|ŧ=ŸûÚÄĮۄDŽZO?6Wk?ƒ•kÁĸ<,ßf<+6-F’*G5ö´ÁÅ+Á:ēŸŧ—D7=šíŠģ\Ņî=’ŊÍŊFŋ=Yž^dD>7ŋ ?Ô=į@?>%F´BĻ͑CÔĸC–ZŦ>wdĒ=…"Â7'úAžC€€Ô=Bn§C€~ĩ=A&ĢÅ(čÉD`}">ĒI?†Á˛Áļ<0uÕ<.š1G5ö´G5ö´Á:ēŸÁ:ēŸŧ,Ž="š‹ģ´ƒō=ā‹ŧˇrãSC=(ÉĶÂ;Ôl@Ķ'OC€=VC€Z=’uÅH¤īDį>JŊ÷?АÁ,Z<û˙<ʋG5ö´G5ö´Á:ēŸÁ:ēŸģ‡ ī<ÄĸËģ‹Zė<Ŋ -ŧ€2y<+ĩ Ŋ…*j<ƒP+ >LDų@?žC„Eëü;BNxdCƒ™xC:>Gęv<íģâÂ:cö@œ-;C€:<ËũC€R<Čö]ÅMU CãBˇ> Ģu?U}Á;ô";ōYĶGrâ‡G’ŽÁ?žáÁC‘ ģ!ēí<ˆ§­ģņÚ<ƒ\qŧRč< ƆŊ-xÂ<#~į ={Ō@?ŅJEEÜ6DB*ĀNCYĒyCéŊ>C%6<ˇŲîÂ9ī@vĶ~C€€č<Ŧ ĒC€(<Ēv*ÅHÅCĨËâ=ĶgÛ?ŊöÁëm;Ø*Â;Öá3Grâ‡G’ŽÁ?žáÁC‘ ģoŖa<ũ“<„WÂ8ōČ@6‚ŌC€&<‡A]C€S<†C:Å=šųCPĒÃ=ŒŪG?”;xÁ‚Ė;­Ė;Ŧŋ‰G’ŽG’ŽÁC‘ÁC‘ 쀿€<ĄgēËj<Ōŧ ‘B;°uŧŦ{Ž;­đB =Âũ@?ũ=ĩEš@A’ÍBģ"ˇB„S*>5KĮ<ˇBÂ7Δ?Öŋ^C€<<'ĐC€Ņ<&ĒÅ2ČC\Ë=O|Ą?—Î…ÁæÚ;\Ėh;\^G’ŽG’ŽÁC‘ÁC‘ ģžÍą;˜b:;—5TģŊYĩ;‡>Uģ˛1; =L\@@ HWEĻ:ŲAĒVBØČâB™J9>*üû5z;×ZÜÂënú@@(ˆ-EŒãÕA-2ũBm)¨B'ŗ >?<‚ĪÂ1Ü?ŽŠKC€€.<@¤pC€€üI`@@9b˜EËč@ô9¤B2AûŊÕ>D›ä;ŸäüÂ6Xî?WQˇC€é<ÂC€€f<6xÄŽĸAAūe<ēv>?ĻüÆÁŧ;äb;˜dH Ē3H?QÁN5ÁNåv9Îļž;=rUēŗa;5ē¨ųĄ;ø˙ģÍÁ&;-7 > mŗ@@KėtEiŅ@™xAÔf4A–0^>AN‚;Y?Â3î?Õ C€ũ;Á¸3C€ų;Áų˛Ä‰šA´#<¨'×?Ģ›Áëz:¨w:¨0÷H Ē3H9´ÁN5ÁSI%¸Ŗnž:ųë7¸¨įs:ųŽf:ŊÍ:į1‹;M":įô =¸ķ´@@`PæESĖĮ@Ą:äAūíUA´Bģ>AĖr;‹›ũÂ8ļÅ?90 C€€š< 8HC€ų< bkÄW˙§A’ž<­/R4-@@vŋdEABû@'šõAŠø’ADˆŪ>FŸ…;BÂ4ŧZ>ŋĩ:C€ģ; š C€ũ; ’YÄ=ŅAÔ F÷D:ÖŦWÂ18>ŠjsC€€;€ īC€€;€ZÄ&‘-@Ų<&Õq?ˇ¯§Á â¯:<LJ:>G˛b; 9Â3îĻ>°XjC€ü;ļ ÁC€€ ;ļrąô@Ŗ5 <"Šī?ŧ‘Á Î:gÚg:gÃNHtDKH…”ÁWí4ÁYjŽ;E9īģ:ŖôxšĀ¨o:Ĩ0Ø7Yŋ:”§;'ą:•Ė´2?Q÷ @@¤5įE–—?Šæ A:@@ē˙|>I­:”{‹Â35ô>=E*C€ō;W2C€>;WnÃä ī@HI:ŒXāÂ2ëŖ>4bĘC€€T;_˛ÖC€€H;`2Ãļם@ \Ë;Ć?ÅLÁn'9ũX 9ũEH“¸5H e˜Á[;Á\Š9Sa9ĩxm:)2škK:'c[š5:Í:˛Ã:Œö =<‘@@ÆąŪDæ7?%‰M@­>@tÔß>Jdų:R’*Â49j>ˆC€€ĸ;8­†C€ē;8žļÛV‘?Ąx9;… i?ĘÁ›ã9ĮĒ]9Č hHĄįH´^Á\ēÁ^ŗ ayē2yž9÷ũz8:âē9ö´ļ¸3Ã9æē‚+ŋ9äį # <$YH@@ڐuDÍũÚ>É0@^ø@ ]>I¸": āųÂ3žŲ=ļ” C€€+; ~aC€Ŋ; ‰üÃ}æ–?<>§;=Í?ÎíSÁ­v9‡Ę9‡­BHŗĖHÆgãÁ^žÁ`Zúw‘ēi›9ĻŠMš=)ö9ŖÄÆˇœ¯ŋ9ĄN†9örš9žŒ“' ;ŌÃ}@@đk´D¸{g>_$á?˙¤Å?´Äq>Jë¤9Š)#Â4’°=TOC€å:˛ü¤C€ų:˛Ö:ÃRną?īÃ;Ę?ĶęĩĀũ†9(.č9'üƒHÂÖųHÛ´=Á` QÁb q‹ą9Ēž9Kā 7ƒš–9L{ŪÚI9@|,šĘ€ƒ9A$ * ‰¤„@%?ébl>Kee9æ/sÂ3“ĸ=ķC€€;¨ÃC€×;žÃ./>ˁÖ;y?ŲåĀų™,9h7Û9h˙HŨÎŪHõzĀÁbJÔÁd ÆŗŨš¤˜9‰Fž7ā"9Š<ú9ŒŠÔ9đįš˙–Y9‚į. ;~Į@At]D’ĀŠ>9î?깚?ĨųÄ>K9¯UÎÂ3ā’=\ēųC€é:āŋsC€ĩ:āɖà °Ø>„Na:ī X?ŪB Āõ’î9/÷$90ķHđËKI(äÁc¸Áex%Ķšo{9P 39=A9OäŊĩ,N]9IČĩ9Žšv9Iš 3 ;˜K@A D‚ŦX>°Ÿ? s?‰7C>KFļ9—$Â4ķ==ĄĀC€đ:Ô× C€€:ÔÕŌÂč€>:•Ņ:ÍĮ+?㞊ĀņŠÉ94e9đŸIĨIFáÁeECÁg÷ũ=8q*98СãČô98ūú67Q947š9”°Z94wˇ8:ˆQq@A0Dhh~=Ņęã?’Ž?Nœ>K°:9tĀøÂ4C*=iāC€€:ž^C€€:žL9ÂŊ¨û=ö$ž:ĻÆ?éČĀíw8ú›ž8ûdYIŦYIÍåÁfĘÁh‡e/y86ė9q÷¸įœ‘9“ų¸ƒģu9Õj8šmR9ß> ;:ĖĒ@AA™šDNXy=Ļ´ ?sz?+Õs>KÅû9OũÂ42=ĖīC€€ :ąļÎC€˙:ą°qÂ™ČÆ=Š :Œ´+?îēÉĀéU8āmÍ8āĖI÷¯I/9ÁhS¨Áj8°mҏC9ZVˇ“Ø8˙ö˛¸ŸĒĀ8ø19{ˈ8÷}D :öLĀ@ATõÃD7z=kŽI?3>ũJ|>Kց9.&@Â4)s<؞žC€€5:ĸé>C€ë:ĸßÂyBb=PŋÛ:Ve?ô|}Āå-Õ8˛“¯8˛ė“I,) I=PÁiî”Ák™hŊ)š‘­8ŌĀŌˇŪĀā8Ôkޏ"û8Ę]9{`Ė8˯„J :oĮŠ@AjAŠD"bŦ= x>áä÷>ŸģA>Lõ8×E’Â47<†ĖxC€÷:^E_C€ü:^C ÂIĶ<íÅV:_f?úaēĀá18p¤‚8r ’IÔË>7›ė>LMÍ8Ŧ:Â4ƒ§›>lS€>LDŧ8ēŲfÂ4á1%Ą=ú†>LĨ8~û Â4˙<.C€Ų:/0 C€ü:/.•ÁĪÄt<ķ¸9ĻGß@wĢĀԃÅ7ëī[7ęėˇIqÁtI…j¨ÁoÔXÁq‹Cĩą8—mx8€ˇ„g8QZ82¸¸Ŧ8uqm :Rŗ@AĢ|ŗCÉ`E<%×o> “L=ã>L‰8Q‹ēÂ4:<čC€ü:oÃC€€:lBÁ§?#jD<úaŦ@ ĩĀĀĐg-7æbų7ãÚãI„5{Iœ^ÁqbĶÁrņ’Š5XO8萡Z9˙8üļŠ{u7ûNf¸ąŗ7üvhx 9×lm@AŧĸÅCŗ2x< ˜B>ĘH=ËY™>L…s8?SÂ4Š;ģģÄC€õ9ųčŌC€ô9ųčCÁ„Û,>Ô;C<ĖyZ@ ŲĀĖYQ7Ũ 7ŲęJIIœĩ%ÁrÂ~ÁtVÖ{Õ5àÁ7ē]ļA*#7ēŠˇR67ēHU6ŖM7ēnđ„ :bX@AĪŲCŸâK:žu¤<¨§€LŸū8ōÂ3ūÂ;ŗ‹C€Ü:íC€ß:ž+ÁKe>”r”<ē×@nrĀČc6Ĩ6†lI›ßIŠ*tÁt'ÁuĢ -ļĸ p7˛V¸7wé‘7˛Žã6žĨ7ŽDb¸`67Žņú‘ 9–Å@Aä?ÕCä:„$Å<“GVLЁ7ÛiäÂ4ą;ˆbeC€€9ÜaëC€ô9Üa]Á#Ęæ>Jķ§<žš@ę Āćs6uŨ6…œøI¨I¸[ÎÁuŒrÁw)‚ũ ˇQâ/7‡”üļ#đœ7ˆ=6lm7ƒ’ƒ¸Ké‡7„ęŸ 9Q:¸@AûC€‡;ĮW=:Ōö=Å>LĢ7´ÖßÂ3ũŗ;`đæC€ķ9ĮÚ/C€ō9ĮÚßÁ Ú> Di<‰@{ ĀĀÍY70P˜7)ąĀIļXÆIĮãdÁvøļÁx‘- ¯ ĩԚ7`(kļͧ7a"ī6ˆ¯ę7[›]¸dO7\Üf¯ 9Tž@B BCgôĄ;#Å=GÂņ= @ą>LĒ7‹ō^Â4;-?C€€9Ší–C€û9Šė‰ĀÆcņ=Ŧ­<^@";ĀŊ;~7F7@žŨIÅëŨIŲ"ôÁxe3ÁzB ģĄˇĒÄæ7*å-ˇ3y7, ëļ™0đ7*n7Ŧ7+ąüĀ 7ų¤Û@BæbCRi°>L°Ã7_ī‡Â3ūË; mŊC€€9•ÎŨC€€ 9•Ī!šYŒ=€ËpLŧ7;ÅŗÂ3ũ—:é•5C€ü9Š!C€ũ9Š!”Āpã°=ī°<(įö@"´ŖĀļŠÍ6œīø6Ą|IéÎJqÁ{Lŧ­7(yÂ3ũ]:ļÖûC€ū9nĢC€˙9n›Ā7ö<ŌØ7<´3@&Ą Āŗŗ6īüĖ6īEˆIũ™âJ .Á|ŗŸÁ~n$ĢÕ6ŒĒÍ6ļP:ļJ¨6ˇB=6žbm6ŗ” ˇm°6´  8\A@BJ-ČC#o:€+Ų<ž>c<†…Ę>Lžč6Ū՟Â4ģ:Š ņC€ö9F_C€ķ9F^ÎĀBD<Ŧ<Cĩ@*Ĩ¨Ā°ú6ÛE´6Ų<J aŠJz˜Á~8IÁ€f ]5Ÿ—ˆ6Š|`5­‹36‹ģĩęË6‰*ûˇ~Ņ6‰Áũ 7˙ĄÖ@B^eCĘ$:~į¤<ÆXŖ<Œ@>LŋŨ6ļŅãÂ3˙:cQOC€€92øīC€ų92ų ŋŲ&ŋ>LÂã6˜ņÂ4°:>šC€ü9$ęC€û9$éåŋ¤×<[ĸ;ČGM@2ųįĀŦEå3“nˆŗ“nˆJ&.J9€GÁ€ŗ2Á§Ä$Ë-ņļŋ-ƒ6=šĐ´VĄö6=ŨæĩŠ Ë6=Sjļ÷Ų6=­KV 7žf:@B†ŒĪC~>LÃá6pŸÂ3˙Ō:Ą›C€ū9]ŦC€€9]ļŋw’”;¤Žz;ĒI@7JžĀĒKž2áΞáĪJ6Š„JM8{Áƒ˙Á‚ˆg,_7Š6˛įs6ö%ļ|āj6?ųĩTŋg6ã¤4ܰ6={x 7pé@@B”JB˙âh:„n<í8Ų<§ŊČ>LÄđ6RÂ3˙ō:žMC€€9ōņC€€9ōķŋ;é;YęJ;•Ė@;ļ9Ĩ7ģ}7 JIŦ}Jc†ÜÁ‚aŖÁƒmÖ5ˇC)3“-(6Đ^4§+Û6CĀ4di66É4a3°6IF 7ā–d@BĸÎ8Bõ ô9Õ}LÆY6ôôÂ4e9Æ GC€˙8äqC€€8äPŋ |;˸;ƒR@@<ũ§•6<ˇ6aŗ*J_ĪįJ~ĩÁƒI<Á„cA Qiŗ ^5Æ/EĩŠŦ5Ə{4†e$5ÂÆļÁŋâ5Ã=éĮ 7@N°@Bŗ Bė˜>LĮ76 Î Â3˙Ō9Ģ–+C€˙8ŲjŧC€ü8ŲjËžŅƒÃ:ˇõ‹;`Æ@@D߲ĀĨĀ3ÔxwŗÔxwJy‰-JŽetÁ„;'Á…aNŋbiĩĩĻ5́°ĩČĩ—5ŦĩŽ>5ĢÁ5¯§b5Ģ­jô 7cR@BÄūĻBäÍÚ>LČ5ÜzhÂ4"9‰BũC€€8ŋQĘC€˙8ŋQÁžš…œ:hŲ;@áå@IŸ¤Ŧē4yLÍ´yLÍJ‹ĪÃJ Ŧ_Á…8IÁ†m‰_Uw=˛†˙ã5‰ecĩmĨ5‰Ë#´ z5ˆē6/6Y5‰:& 6׆ē@BØąˇBŪŲ>LČŋ5Ŋ,UÂ49kŠäC€€8´’C€˙8´’ žc=h:†k;%Ø@N{ĻĀŖÂ4HR´HRJcĄJļ !Á†?Á‡ƒ'sG)5Y…5kiíĩ’‘L5l-´†iå5jÜN5ģ)ã5kĄ‡] 6 lļ@Bî]BÚŋ>LÉË5š[Â49@ ŖC€˙8ĸC€€8ĸž&Ř9Ŋåë;ŋņ@SvJĀĸ˙iŗ¯u3¯uJ˛)&JĪdEÁ‡SCÁˆĨ‹kŽM4ž‰ģ5@.˛ĩ@(Ä5@âŗ˜5@‚4Âh35@á‘™ 6H”Ë@C™BÖ">LĘc5„BĸÂ49$ĒC€ū8˜ĀõC€ũ8˜ĀđŊķ‡Ũ9už[;)Ā@XĒĀĸ_d4*F´*FJĘáwJíÅzÁˆt-Á‰Õ ¨šŌũ2û)û5$ŖOĩŠHõ5% 1Œ[¯5$/ƒĩŸ`!5$ŧÜ 6Ø@C5ÂBĶt>LĘŖ5^f7Â3˙˙9 hC€˙8AĐC€ū8AĐŊ¯øy9Få:Ũ‡Ū@]Č…ĀĄŨ4ĐĶ´ĐĶJčQKö€Á‰ĄtÁ‹ÉĖ1˙5ŗ…Į§5 –ĩ_Z5 Ŋ4„ŗģ5 uĩ¨ō5 †c% 68Ŋ‰@CĄ‰BЊÆ9ÄgŌ<3<6–ß>LË5BPįÂ3˙˙8ņ¸āC€€8‡ēQC€˙8‡ēQŊ}Tæ8ž~Ž: *@c!ĀĄsū6Ū$6…L]Kˇ=K¨NÁŠÚoÁŒVŧ÷4Ņ´sĶø4ņÚŠĩ9Ø]4ōÄ´¤4ņ@ĩqå.4ō@ u 5Į8ā@C.~}BΔz9FIû<žÚ;Ác*>LË5#ŗäÂ3˙˙8ËŋC€ū8{ÍC€ũ8{ÎŊ5ƒ8•G’:Ķ@h›¸ĀĄę6ļį5úÂöKąēK8´ˆÁŒ|Á¨Ā*Ķuĩŗ%-4Ëøĩ`:4ĖĄã2š.á4Ëļ05~94ĖsÆÎ 5ˇ@í@C?ņŠBÍ m9ņĮ<;đr<äŖ>LË~5ŪyÂ3˙˙8˛ūC€ũ8s4=C€ū8s4>Ŋ§8dB‰:ã @n7ĸĀ Ũ96256)“K3öKWøļÁnåÁ}iŗÄ4V™4ŗ\ˆ´ đŨ4´ŗļOx4˛âKĩMˆđ4ŗš&/ 4ĮŦe@CS#KBËÔŽ>L˔4˙öĮÂ3˙˙8Ÿ6}C€ū8mų?C€˙8mų?ŧĩÂ[@sö-Ā ¨Āŗ„33„3KROLK}ŦXÁŽÉkÁjCĩš#4Bje4Ÿ~,´š_ 4 ŠŗNO¤4Ÿuü2%„E4 ūš 5.†„@Ch@lBĘäõ>LËÆ4ãŸÂ3˙˙8C˜C€˙8h=ŸC€ū8h=Ÿŧ~Ķr7¸@i:šē@yØ-Ā Ė4û´ûKvĘŲK•”hÁ-Á‘Ų—–)´”'Ŗ4yŽ´4úaŗ‰É4hú´c‚›4÷o 3ˇōJ@CzBĘ,:{<ŨR<œHT>LĖ4ΜoÂ3˙˙8ãC€˙8i†“C€ū8i†”ŧ1bN7Ĩđ­:ī{ø@Ū~Ā `6ŧ+œ6ŽĮĄK‘dmK°ķPÁ‘™ŨÁ“N§€Ņ! ´ˆųP4O|´“ÉÚ4Ķ'ŗ€…u4:Ž´čŌ4ĘË‘ 4yNÖ@CŒƒ#Bɞm>LĖ4ŧÎÂ3˙õ8pmC€ū8kÛģC€˙8kÛŋģõŒH7€N;ħ@ƒĀ G´˛ÖZ’2ÖZ’KĢéLĖ5ĄcÂ3˙õ9c C€ū8đÉFC€˙8đÉQ썐=@†-ĖĀ 5ŗ† 3† KÁēKĶ›(Á”ßÁ”Ü[rŸŅc˛ĄĢs4툠´ģ×Ú4´úĀ´}v]4Îĸaĩ›?4ÉÔî,‘5eß\@././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/tests/data/synth_lowsnr_table.fits0000644000175100001660000007020014755160622025727 0ustar00runnerdockerSIMPLE = T / file does conform to FITS standard BITPIX = 16 / number of bits per data pixel NAXIS = 0 / number of data axes EXTEND = T / FITS dataset may contain extensions COMMENT FITS (Flexible Image Transport System) format is defined in 'AstronomyCOMMENT and Astrophysics', volume 376, page 359; bibcode: 2001A&A...376..359H ORIGIN = 'STScI-STSDAS/TABLES' / Tables version 2002-02-22 FILENAME= 'synth_lowsnr_table.fits' / name of file NEXTEND = 1 / number of extensions in file END XTENSION= 'BINTABLE' / binary table extension BITPIX = 8 / 8-bit bytes NAXIS = 2 / 2-dimensional binary table NAXIS1 = 160 / width of table in bytes NAXIS2 = 55 PCOUNT = 0 / size of special data area GCOUNT = 1 / one data group (required keyword) TFIELDS = 40 TTYPE1 = 'SMA ' / label for field 1 TFORM1 = '1E ' / data format of field: 4-byte REAL TUNIT1 = 'pixel ' / physical unit of field TTYPE2 = 'INTENS ' / label for field 2 TFORM2 = '1E ' / data format of field: 4-byte REAL TTYPE3 = 'INT_ERR ' / label for field 3 TFORM3 = '1E ' / data format of field: 4-byte REAL TTYPE4 = 'PIX_VAR ' / label for field 4 TFORM4 = '1E ' / data format of field: 4-byte REAL TTYPE5 = 'RMS ' / label for field 5 TFORM5 = '1E ' / data format of field: 4-byte REAL TTYPE6 = 'ELLIP ' / label for field 6 TFORM6 = '1E ' / data format of field: 4-byte REAL TTYPE7 = 'ELLIP_ERR' / label for field 7 TFORM7 = '1E ' / data format of field: 4-byte REAL TTYPE8 = 'PA ' / label for field 8 TFORM8 = '1E ' / data format of field: 4-byte REAL TUNIT8 = 'deg ' / physical unit of field TTYPE9 = 'PA_ERR ' / label for field 9 TFORM9 = '1E ' / data format of field: 4-byte REAL TUNIT9 = 'deg ' / physical unit of field TTYPE10 = 'X0 ' / label for field 10 TFORM10 = '1E ' / data format of field: 4-byte REAL TUNIT10 = 'pixel ' / physical unit of field TTYPE11 = 'X0_ERR ' / label for field 11 TFORM11 = '1E ' / data format of field: 4-byte REAL TUNIT11 = 'pixel ' / physical unit of field TTYPE12 = 'Y0 ' / label for field 12 TFORM12 = '1E ' / data format of field: 4-byte REAL TUNIT12 = 'pixel ' / physical unit of field TTYPE13 = 'Y0_ERR ' / label for field 13 TFORM13 = '1E ' / data format of field: 4-byte REAL TUNIT13 = 'pixel ' / physical unit of field TTYPE14 = 'GRAD ' / label for field 14 TFORM14 = '1E ' / data format of field: 4-byte REAL TTYPE15 = 'GRAD_ERR' / label for field 15 TFORM15 = '1E ' / data format of field: 4-byte REAL TTYPE16 = 'GRAD_R_ERR' / label for field 16 TFORM16 = '1E ' / data format of field: 4-byte REAL TTYPE17 = 'RSMA ' / label for field 17 TFORM17 = '1E ' / data format of field: 4-byte REAL TUNIT17 = 'pix(1/4)' / physical unit of field TTYPE18 = 'MAG ' / label for field 18 TFORM18 = '1E ' / data format of field: 4-byte REAL TTYPE19 = 'MAG_LERR' / label for field 19 TFORM19 = '1E ' / data format of field: 4-byte REAL TTYPE20 = 'MAG_UERR' / label for field 20 TFORM20 = '1E ' / data format of field: 4-byte REAL TTYPE21 = 'TFLUX_E ' / label for field 21 TFORM21 = '1E ' / data format of field: 4-byte REAL TTYPE22 = 'TFLUX_C ' / label for field 22 TFORM22 = '1E ' / data format of field: 4-byte REAL TTYPE23 = 'TMAG_E ' / label for field 23 TFORM23 = '1E ' / data format of field: 4-byte REAL TTYPE24 = 'TMAG_C ' / label for field 24 TFORM24 = '1E ' / data format of field: 4-byte REAL TTYPE25 = 'NPIX_E ' / label for field 25 TFORM25 = '1J ' / data format of field: 4-byte INTEGER TTYPE26 = 'NPIX_C ' / label for field 26 TFORM26 = '1J ' / data format of field: 4-byte INTEGER TTYPE27 = 'A3 ' / label for field 27 TFORM27 = '1E ' / data format of field: 4-byte REAL TTYPE28 = 'A3_ERR ' / label for field 28 TFORM28 = '1E ' / data format of field: 4-byte REAL TTYPE29 = 'B3 ' / label for field 29 TFORM29 = '1E ' / data format of field: 4-byte REAL TTYPE30 = 'B3_ERR ' / label for field 30 TFORM30 = '1E ' / data format of field: 4-byte REAL TTYPE31 = 'A4 ' / label for field 31 TFORM31 = '1E ' / data format of field: 4-byte REAL TTYPE32 = 'A4_ERR ' / label for field 32 TFORM32 = '1E ' / data format of field: 4-byte REAL TTYPE33 = 'B4 ' / label for field 33 TFORM33 = '1E ' / data format of field: 4-byte REAL TTYPE34 = 'B4_ERR ' / label for field 34 TFORM34 = '1E ' / data format of field: 4-byte REAL TTYPE35 = 'NDATA ' / label for field 35 TFORM35 = '1J ' / data format of field: 4-byte INTEGER TTYPE36 = 'NFLAG ' / label for field 36 TFORM36 = '1J ' / data format of field: 4-byte INTEGER TTYPE37 = 'NITER ' / label for field 37 TFORM37 = '1J ' / data format of field: 4-byte INTEGER TTYPE38 = 'STOP ' / label for field 38 TFORM38 = '1J ' / data format of field: 4-byte INTEGER TTYPE39 = 'A_BIG ' / label for field 39 TFORM39 = '1E ' / data format of field: 4-byte REAL TTYPE40 = 'SAREA ' / label for field 40 TFORM40 = '1E ' / data format of field: 4-byte REAL TUNIT40 = 'pixel ' / physical unit of field TDISP1 = 'F7.2 ' / display format TDISP2 = 'G10.3 ' / display format TDISP3 = 'G10.3 ' / display format TDISP4 = 'G9.3 ' / display format TDISP5 = 'G9.3 ' / display format TDISP6 = 'F6.4 ' / display format TDISP7 = 'F6.4 ' / display format TDISP8 = 'F6.2 ' / display format TDISP9 = 'F6.2 ' / display format TDISP10 = 'F7.2 ' / display format TDISP11 = 'F6.2 ' / display format TDISP12 = 'F7.2 ' / display format TDISP13 = 'F6.2 ' / display format TDISP14 = 'G8.3 ' / display format TDISP15 = 'G6.3 ' / display format TDISP16 = 'G6.3 ' / display format TDISP17 = 'F7.5 ' / display format TDISP18 = 'G7.3 ' / display format TDISP19 = 'G7.3 ' / display format TDISP20 = 'G7.3 ' / display format TDISP21 = 'G12.5 ' / display format TDISP22 = 'G12.5 ' / display format TDISP23 = 'G7.3 ' / display format TDISP24 = 'G7.3 ' / display format TDISP25 = 'I6 ' / display format TNULL25 = -2147483647 / undefined value for column TDISP26 = 'I6 ' / display format TNULL26 = -2147483647 / undefined value for column TDISP27 = 'G9.3 ' / display format TDISP28 = 'G7.3 ' / display format TDISP29 = 'G9.3 ' / display format TDISP30 = 'G7.3 ' / display format TDISP31 = 'G9.3 ' / display format TDISP32 = 'G7.3 ' / display format TDISP33 = 'G9.3 ' / display format TDISP34 = 'G7.3 ' / display format TDISP35 = 'I5 ' / display format TNULL35 = -2147483647 / undefined value for column TDISP36 = 'I5 ' / display format TNULL36 = -2147483647 / undefined value for column TDISP37 = 'I3 ' / display format TNULL37 = -2147483647 / undefined value for column TDISP38 = 'I2 ' / display format TNULL38 = -2147483647 / undefined value for column TDISP39 = 'G9.3 ' / display format TDISP40 = 'F5.1 ' / display format IMAGE = 'synth_lowsnr.fits' END FGŸ˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙C€}ā˙˙˙˙C€}r˙˙˙˙ÃX‘đ˙˙˙˙˙˙˙˙ÁŧC˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙?__F„MA’§āBēķ™—>2EžÂHĻ AĄÔŽC€}ā=nMC€}r=adÃņwlD Ε?“)[?Y~`Á…ø;/; ØQFGŸFGŸÁŧCÁŧC;†mh=čdž=c&>ž)>¤bžŖ +>ģʃ >_¨@?ĩĩFĖAŗ"ABäZBĄx>˜đ7>1G@ÂF AĄ~HC€~=Ą|C€}G=vŨ€Ä˙¨DŽ)?‘˜:?^ŧüÁy‹;.,C;-škFGŸFGŸÁŧCÁŧCģžAŌ=į`j=jÕ>dŊîĄ> lŋž¤ $>ģ >ōw@?!azFŸžAŲ`0C ŒæBÃđ™>˜ x>0ëxÂEņëAĄōC€~!=Ž—C€}E=‡RŠÄwD)rį?÷å?dųÁj7;Tî;S€RFGŸFGŸÁŧCÁŧCģŌ‚‹=įC‡=LK9=ú2YŊî!>Î žŖíU>šū =“Hi@?1„ĶF„B˛VC'á„Bíkb>—†Ä>0?uÂE]UAĄÉĶC€~3=›^C€}0=”IGÄ%ĨŨD:Ĩ?9~?iœÁWƒ;Æ;€ŖëFGŸFGŸÁŧCÁŧCŧ \—=æåå=4P=ķ]ŦŊįų>žÜžŖÎĻ>¸įn >ƒō@?CEOF KëB×:CKÁüC>–Ų>0žÂDų1Aĸ*rC€~M=Ē[C€}2=ĸÍoÄ7]/DM÷Ļ?ĮT?o>6Á@Ą;Œđ;œā„FGŸFGŸÁŧCÁŧCŧ"ļP=æîĩ=ų(=í8ŌŊäĢŊ>‡*žŖĐK>¸Wļ >Ĩéķ@?VĖ>F jáBAs~CvšPC._ß>–Ų>/UÆÂD…9AĄ‹˙C€~m=ē‘ĻC€}=˛‚ëÄJˆ’Dbĸ7?;.?uÁ$ā;ŋ÷Ã;žõ'FGŸFGŸÁŧCÁŧCŧ@ž=æĪo=ØŦ=čŊßķ=ūâAžŖ°ŋ>ˇ' ?Ÿu@?lGF VYBjöųC•ÂāCSË[>–8>/^ÂD?AĸHĖC€~„=ĖŌ`C€}=ÄEUÄ`[Dz8 ?Žđ?zë’Áˆ;ëj;é˜ÜFGŸFGŸÁŧCÁŧCŧS¸E=æņ.<ßû =äāįŊÛÄ=ųÖížŖÅú>ˇ9û ?7/@?ķãF ŒBŽ[LCĩxEC€Q‹>–8>/rÂD$Aĸ’C€~ž=ā÷ÍC€|ū=ׇÄw8DD‰ĪČ?Ž´Å?€|CÁØų<æØ<žeFGŸG6 ÁŧCÁ:ŧxŧ`Tõ=æīy<Į¸˜=âáąŊÜŧ~=úz“žŖŒ>ļ­- >aMY@?Žō­FtmBŦŌ˙CÜNúC›Č>•‚Ę=÷ŌiÂD‹AfšC€~ŗ=Žđ~C€|í=§¤ļÄÁ/D“Æ?C 8?ƒ•kÁĨ‘<0â 0ᥠ?€Î@?>%F1‡B˛–šCã¨C ú1>yšū=ŒûCÂ;ķAdŦC€€Ž=OZ´C€=KädÅ*<DqĀ >ĩÅæ?†Á˛Áy<<žr<:ÄQG6 G6 Á:ŧxÁ:ŧxŧk07=- )ģj—=%4.ŧ˙W&SōJ=3'-Â? @ßz C€€ū= ė(C€û= D<ÅIHD*Â>XVw?АÁ-ļ<Ŗ¨<BîG6 G6 Á:ŧxÁ:ŧxģū _<Đßjē?`Z<Č_ ŧ™ā<6iéŊŒ˙%<=? =ö´R@?žC„EëķBXH÷C‰ÚîCBôé>Hø\<øĢ˛Â;Ē @ĸ˜äC€=<ÔĀ\C€€ <Ō$-ÅM]ĐCî)Ũ>q?U}Ág;˙˛ë;ũāĢGrŲĸG’"Á?ž>ÁC ģÆķœ<ŽļŌ¸ļGj<‰ĖŧY[[<ŗ Ŋ7*6<*Ȍ >Z@˙@?ŅJEEÜ0îB.[C^BÆC)‚>E˙<ģ˜RÂ9d(@yŠC€€â<¯ˇvC€á<Ž<"ÅHtŠCĒ7Ŋ=Ųb%?ŊöÁë;ÜÉĮ;ÛeĢGrŲĸG’"Á?ž>ÁC ģíæ?€“<†ÄÂ6ã€@7ͧC€<‰āC€€ <‰EúÅ=—C\?ž=•"ô?”;xÁƒ;°č ;°­G’"G’"ÁCÁC ŧķí<Eš€ō<=Ŗģøŧ];ąŦ%ŧŦˇû;¯ņņ =”¯@?ũ=ĩEš×AŠ„BגB˜nr>7&<<-Ī×Â49?÷RC€,šö<§œÂ/ũv@<9ÜC€€<šUC€n<›-LÅĨC¨=€Ŋ?›w¤Á ˙;Ē#;ŠTjG’"GēëËÁCÁG<´ ŧyUÖ;įyēō2–;âĮ/ģ$éĶ;•˜<…Ëe;—­œ @čh¨@@5úE˜—A:YģBv„ĀB.P >4-<2Â5Ū‰?ŧ†ŋC€~Õ<0—(C€€ē<0:’ÄŅäžBĻåT=KŽÁ?Ÿ7[ÁŒ ;)ņ;)7GâëÕHgpÁJ›ÁM ēÅrX;“%ēFā'; cģ ¤Ŋ; DŽ븛@@(ˆ-EŒŠ‹ACgđB…É B=3x>=ÂV<R€Â-‚Õ?ČéC€Ā<[îC€€û<][ŠÄŦyKByņ=8Ü?Ŗ7Á";AP@;@ĪYGâëÕHgpÁJ›ÁM ģaûÎ;Ĩ-:Áĸ;ŖÚŽģ-qĨ;ĸŊÉ痁=;ĄŠN ?P1m@@9b˜EĘėAąXBa€aBt!>D—;ËéÂ4?ˆ´ÔC€€{<(iC€b<(4ÄŽ3˙Bô&<āÅá?ĻüÆÁŧk;%×Č;%oJH ‘‡HģÁNÁNáēʕ ;oāÄ:šß[;eEĒģiûH;DËģŨ˛ũ;:˙™ =đĪÄ@@KėtEiöy@Ŧ™4BrįAˇ|>B,>;„ÅÂ3‘ã?-.C€€ˆ;ëųŸC€;ė9ĉ3–B kz=Ŗ?Ģ›Áî@:Í%l:ĖôØH ‘‡H9gÁNÁSĄ%:=ÅK;9ˇ;}Ē;2ī9át—;á};q;ŧ~ >[Ėņ@@`PæET;^A•ŸBf0rB"Ä´>DŊß;ü_ŦÂ:¤Ģ?ĨxnC€äît;>fēH,ĘîH9gÁQéģÁSĄ!%;VîB;ĖŨ;›;‹Ô›9"y;‹2<;ZYž;‰6 >t2@@vŋdEA†-@ēžûBŧėAÚÕ!>FÄü;ĸ.zÂ7ŸĨ?SC€€w<1^ŌC€‚‚<0‰ąÄ?Ō AĒQ<ãMģ?ŗ\pÁ ĸ[;!,;æ.HEŅVHP=^ÁTC:ÁU')-;˛ž;&*;w\-;#[ë;ģÖr; ëYēĐĀĖ; LH ={iÄ@@‡ļE.šō@Ē­íBĐ AŅ é>D"č;˜×ëÂ3( ?G@lC€€˛<6-íC€‚‰<6cÄ(Ũ…A…Ãb<ĘÉ?ˇ¯§Á Ûđ;Ũâ;ĨHEŅVHnéÁTC:ÁW{Í)9;̀z;m&;%yj;];ú¨:×{Á9ēēÁ:Õ  <1”ü@@•HFEŧ„@‘ÁBC Aŧuô>C%X;•øfÂ6ė?HtąC€€(JHc;ŠîŽÂ5ö?XTC€}ßO’i;–@Â4å?;ü}C€{<PZĸ;PpũÂ5Ĩ?QEC€|´<8uC€~V<7ĒÜĶz@õúĀ<ČÄ%?ĘÁŠĒ:Či…:ČĐHŸĮH´B×Á\…”Á^°u_yēŪ?:;|f;:ėŸ[ēû:âÎģ-G:ā!# <§*`@@ڐuDÍ-ĩ@WV´AíēF’;œÜÂ3ČÔ?KĻC€€É<–ĩC€‚)<–Ā;Ãw@@Õåc<Ũw'?ÎíSÁ›ß;û;ēkHŗe`HÆpÁ^›Á`[ąw‘ģ=&ũ;&Û˛<!6;$Úģ{ú`;ũ]ģw‰;Ĩ ' =ĩ@@đk´Dļ‹n@6n\AĶy{A•ˆû>:‡a;“ŸŖÂ4/ė?J=:C€-<›B–C€ƒŠ<›9lÃKpŸ@ȕj<ügá?ĶęĩĀũ(; ’; „HŃvHÛŽĶÁ`FîÁb ą9 F;Ø<$ä;åĘģ˜; jŗ;iđ1; !Ū+ =Ä0@A;=D¤ #@j‚[BkAČöÚ>>Ú!;ʰËÂ9}V?‡ŽĨC€}<ë  C€~ˆ<éõiÃ+ÆZ@Đw˜=W|?ŲåĀųqo;FöŲ;FbōHŨŧŦHõ-ÁbIgÁd8ŗŨ;‹,;n^;Đę;lxŋ:z6ģ;låŽ:ëˇK;km„/ >’<´@At]D‰ånA<&B¸ÄÅB‚Ϟ=ÚŸ;Ō};Âeų'?ęBC€€= C€€<ųL&à Č'AOâ0=Áô?ŪB ĀķiQ<î8<įHû´ĘIQŲÁd},Áe}|į쯠ō;bŽã;Ō";_Âģ/dË;^&:öęĶ;] 62B~O@A Dtzv@øßŊB¨÷jBnôe=ÚŸ;éãfÂeų'@,ĀC€€=‡ĨC€€=ĨHÂŨēāA%åˆ=ŋ‰ƒ?㞊Āī9Z< /< ę7I ËIk×Áf7ãÁgZ=ģ*°y;{BÉ;§ˆr;~'í;Ƌĸ;qD;Ցë;s˛;2B{—@A0De†@No‰BŦTAË/=>>ļE<B[Â.‘Ú?°ÃC€‰Û=KØÄC€vŲ=M\ĨÂ̈æ@“ĐÁ=\™á?éČĀí;z)û;yNŠI_IôÁföģÁh‹5y;ž Õ;œ&<;œÔÅŧû;‡€Ã<~4;‰´ > ?*$ú@AA™šDRŲm@IhˇBˇĻAÎT>Z ;ķČÂ7ŧx?ˆC€‚h=RbC€pÎ=Q& ‰@aô =4(Ü?îēÉĀę$;…;„€&I{I/NÃÁh)cÁj?eŅģÕM!;”KŠ;Āpĸ;”œü8ŗ';’•Ō;Ą‰Ķ;“3FC >’ĶĐ@ATõÃD8á’@7{jB ‚~AÅKė>O9;ōQ]Â5]Ú?”āGC€=cĢ@C€zũ=c2ˆÂ‡H@L>=A?ô|}Āå„&;Š/é;‰¨.I+ĸI=ÕwÁiäcÁkĄš);ž×ŋ;”(ē{';”Ņ’O9<+ĪzÂAÔF?Ô¯rC€Ā=ŗžæC€‰=°*âÂFīQ@=Ô=tGō?úaēĀá û;ĩ™~;´ĢĄII?;<YÂ3ą2?´Ÿ~C€‰=ĄØXC€‚ =ĄęøÂ͔@åx=i”ä@5ŽĀÜÂą;”įø;”GSINxGI`x]ÁmŗÁnŠn•%;įU8;ŽÚģ@ëZ;Ž`×ŧ:¯_;ŠX7욌c;Š iZ >O͜@Aš’D?ō@87ÆB!0ÍAãõ>>UPĖXG<8ÉÂCš ?ڗ'C€Ž˛>õtC€y,=ûˇÁĐá¤?éW_=Žũ@wĢĀÔĶ;Ä.Š;Ã8IqCNI…ĢģÁoËEÁq“ē­ą<4÷;ã܍<&ã˜;ä÷V<`3;â(ģa;âžl ?2ū@AĢ|ŗCŗ‚@Û§BMˇAÎį>.ō~<†.āÂCš @@= C€u%>HxTC€yĩ>D ‹Á~ ø@,ˇx>. l@ ĩĀĀĪžq;Ķl;Ō/‡I…Õ˙IæöÁq™8Árú…­Š<ļŪ<(q;WôE<_ˆ;˜Xw< Kŧ?4M<U¸z ?ÜfE@AŧĸÅCą“@ 2ņBl’AÉk>2Ķ)eąC€oO>)8ēÁŠ ã@á=ōũM@ ŲĀËī;;Û*Ž;ŲÖPI‘,Iœ˙äÁsÍÁt_ĨÕ<ÕÆ;ú‹:;ø:Ē:c$;ö.ūŧ|Ę;ų~™† ? šø@AĪŲCĸ •@ 7,BfAÍ|>Zf<]*ĮÂ%pŨ@ĨÆC€_ā>IlC€z>N2ÁZ’ˇ?øZ—>pĢ@nrĀČÚ;ė%Æ;ę™ I™ø6IЌÕÁtrÁuĩ y-;ú´,< %=<‘ū< yšģģ”p< ZÕ;”s< ȏ2@Œz@Aä?ÕCšn@AWB%́Aęƒ~>Zf<zÂ%pŨ@|‡C€_ā>‚}C€z>…6Á0š*?Ę;ė>“ę@ę ĀįQ<ę<ŧáI§5YI¸†8ÁuwGÁw-€ß ŧ 9Ŧ<$¸ģītÔ<$!gŧ#PN<#ö;‚<"Ęcž2AG@AûCā[?÷ÚúBAËĀu>ZįC€kˇ>ƒģÃC€Íx>†ÛŨÁØ?šÕ_>Ļí@{ ĀÁ*1<H<¸IĩIČ-Áv×ņÁx•j  ŧPâs<īî:eĨ <”ZģŧšA<(<_n<JÔ­2@Rp@B BCin“@ ‡8B+Š“AōÄn>Zį<ŋkHÂ%pŨ@aˇC€Vē>čGC€ĩĄ>íčŅĀŧ́?¨S>cˆ@";ĀŊsė<(}<&‹ÕIĈŖIŲw„ÁxEéÁz ƒĄ:qĘB`)<EéÂD}r@;ŽC€ĸĩ>ÁŠ%C€i<>Ŋ7¸ĀģėĮ?Ũ>Bîû@ßãĀš×/<@–<>IØ Iëį}Áyė-Á{qíUŧļŗL<;¨u69Ė=DÂTÆõ@ĐåC€Õ1?_NqC€„ŧ?UķŊĀ*t3?jâÆ>°b_@"´ŖĀļąu<"ßĘ@ŒS=CY'ÂD~AąC€ßZ?žiûChÄ?šĐŋå*H?Eč…>Ũ?@&Ą žŧ<0°Ë<.ōēI˙­/J ?2Á|×ŪÁ~sŲ Õ=!ûČ= Ú3<Õ\(<˙üŧ˜G<öM3ģĢĐš<īaĮ? kw@BJ-ČC6r?š˛B ūšAÅû›>r¸=ׁÂ.b@Ôf’C€Ŧå?aâĄC€R1?cG“Ā #ä>Ķr>CėŦ@*Ĩ¨Ā°(<"ÖE!€Ú<ŅčÂ'@Ú@ĄœC€˙ų?F"C€Ÿ[?I ¸Ā ‘?â>}uō@.ÃĀŽZŸ<*z<(Ũ3JhŧJ(ĢkÁ€1Á€ÔBí%ųģˇLėG­Ö=R1ŨÂ'@ÚAō0CfÖ?ßģ”CŦ-?ãÔ0ŋ’•‘>˛J>›|&@2ųįĀŦ9’G­Ö=EĶÂRæ@û”dC€Ũ?îB0C~ĩô?äU”ŋvŗ>ŽĐ>”šÄ@7JžĀĒč<7’<5) J7`ĸJM‘ÁŽ)Á‚Œ+,ĩ7Š=b‚ß= Töŧ9›7<õ‹n=ab=q =ąĻ=HVy2?ßą@B”JBūņ,? ˛B AΐW>(‘=<9‚ÂWTA ËSC~?ô29C€~?ęHAŋ`W9>k˙˙>†Ļ˙@;ļ9nÔ<0ē<.VåJNämJcõ$Á‚šqÁƒr 8-C)<Ō<æĀņŧrŸ<âįĩ=–0ž=üĩŊ7@,<ō ȧ ?^Ư@BĸÎ8Bô’ō?ž}PBh8AĶK&>IĀT=؀ÕÂWTAˆoVCŠŧ@žŊRC‚Fí@–áČž§×u>\¨­?(Gš@@<ũĀĻũŒ<5<36ÖJ`dËJ~8ÁƒOÁ„fíAOQi=ģÔj=ļ0Jŧ2w˙=‡IU>Cĸ{>ĢęŊz4 =œv@Į?Ž[T@././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/tests/data/synth_table.fits0000644000175100001660000007020014755160622024323 0ustar00runnerdockerSIMPLE = T / file does conform to FITS standard BITPIX = 16 / number of bits per data pixel NAXIS = 0 / number of data axes EXTEND = T / FITS dataset may contain extensions COMMENT FITS (Flexible Image Transport System) format is defined in 'AstronomyCOMMENT and Astrophysics', volume 376, page 359; bibcode: 2001A&A...376..359H ORIGIN = 'STScI-STSDAS/TABLES' / Tables version 2002-02-22 FILENAME= 'synth_table.fits' / name of file NEXTEND = 1 / number of extensions in file END XTENSION= 'BINTABLE' / binary table extension BITPIX = 8 / 8-bit bytes NAXIS = 2 / 2-dimensional binary table NAXIS1 = 160 / width of table in bytes NAXIS2 = 69 PCOUNT = 0 / size of special data area GCOUNT = 1 / one data group (required keyword) TFIELDS = 40 TTYPE1 = 'SMA ' / label for field 1 TFORM1 = '1E ' / data format of field: 4-byte REAL TUNIT1 = 'pixel ' / physical unit of field TTYPE2 = 'INTENS ' / label for field 2 TFORM2 = '1E ' / data format of field: 4-byte REAL TTYPE3 = 'INT_ERR ' / label for field 3 TFORM3 = '1E ' / data format of field: 4-byte REAL TTYPE4 = 'PIX_VAR ' / label for field 4 TFORM4 = '1E ' / data format of field: 4-byte REAL TTYPE5 = 'RMS ' / label for field 5 TFORM5 = '1E ' / data format of field: 4-byte REAL TTYPE6 = 'ELLIP ' / label for field 6 TFORM6 = '1E ' / data format of field: 4-byte REAL TTYPE7 = 'ELLIP_ERR' / label for field 7 TFORM7 = '1E ' / data format of field: 4-byte REAL TTYPE8 = 'PA ' / label for field 8 TFORM8 = '1E ' / data format of field: 4-byte REAL TUNIT8 = 'deg ' / physical unit of field TTYPE9 = 'PA_ERR ' / label for field 9 TFORM9 = '1E ' / data format of field: 4-byte REAL TUNIT9 = 'deg ' / physical unit of field TTYPE10 = 'X0 ' / label for field 10 TFORM10 = '1E ' / data format of field: 4-byte REAL TUNIT10 = 'pixel ' / physical unit of field TTYPE11 = 'X0_ERR ' / label for field 11 TFORM11 = '1E ' / data format of field: 4-byte REAL TUNIT11 = 'pixel ' / physical unit of field TTYPE12 = 'Y0 ' / label for field 12 TFORM12 = '1E ' / data format of field: 4-byte REAL TUNIT12 = 'pixel ' / physical unit of field TTYPE13 = 'Y0_ERR ' / label for field 13 TFORM13 = '1E ' / data format of field: 4-byte REAL TUNIT13 = 'pixel ' / physical unit of field TTYPE14 = 'GRAD ' / label for field 14 TFORM14 = '1E ' / data format of field: 4-byte REAL TTYPE15 = 'GRAD_ERR' / label for field 15 TFORM15 = '1E ' / data format of field: 4-byte REAL TTYPE16 = 'GRAD_R_ERR' / label for field 16 TFORM16 = '1E ' / data format of field: 4-byte REAL TTYPE17 = 'RSMA ' / label for field 17 TFORM17 = '1E ' / data format of field: 4-byte REAL TUNIT17 = 'pix(1/4)' / physical unit of field TTYPE18 = 'MAG ' / label for field 18 TFORM18 = '1E ' / data format of field: 4-byte REAL TTYPE19 = 'MAG_LERR' / label for field 19 TFORM19 = '1E ' / data format of field: 4-byte REAL TTYPE20 = 'MAG_UERR' / label for field 20 TFORM20 = '1E ' / data format of field: 4-byte REAL TTYPE21 = 'TFLUX_E ' / label for field 21 TFORM21 = '1E ' / data format of field: 4-byte REAL TTYPE22 = 'TFLUX_C ' / label for field 22 TFORM22 = '1E ' / data format of field: 4-byte REAL TTYPE23 = 'TMAG_E ' / label for field 23 TFORM23 = '1E ' / data format of field: 4-byte REAL TTYPE24 = 'TMAG_C ' / label for field 24 TFORM24 = '1E ' / data format of field: 4-byte REAL TTYPE25 = 'NPIX_E ' / label for field 25 TFORM25 = '1J ' / data format of field: 4-byte INTEGER TTYPE26 = 'NPIX_C ' / label for field 26 TFORM26 = '1J ' / data format of field: 4-byte INTEGER TTYPE27 = 'A3 ' / label for field 27 TFORM27 = '1E ' / data format of field: 4-byte REAL TTYPE28 = 'A3_ERR ' / label for field 28 TFORM28 = '1E ' / data format of field: 4-byte REAL TTYPE29 = 'B3 ' / label for field 29 TFORM29 = '1E ' / data format of field: 4-byte REAL TTYPE30 = 'B3_ERR ' / label for field 30 TFORM30 = '1E ' / data format of field: 4-byte REAL TTYPE31 = 'A4 ' / label for field 31 TFORM31 = '1E ' / data format of field: 4-byte REAL TTYPE32 = 'A4_ERR ' / label for field 32 TFORM32 = '1E ' / data format of field: 4-byte REAL TTYPE33 = 'B4 ' / label for field 33 TFORM33 = '1E ' / data format of field: 4-byte REAL TTYPE34 = 'B4_ERR ' / label for field 34 TFORM34 = '1E ' / data format of field: 4-byte REAL TTYPE35 = 'NDATA ' / label for field 35 TFORM35 = '1J ' / data format of field: 4-byte INTEGER TTYPE36 = 'NFLAG ' / label for field 36 TFORM36 = '1J ' / data format of field: 4-byte INTEGER TTYPE37 = 'NITER ' / label for field 37 TFORM37 = '1J ' / data format of field: 4-byte INTEGER TTYPE38 = 'STOP ' / label for field 38 TFORM38 = '1J ' / data format of field: 4-byte INTEGER TTYPE39 = 'A_BIG ' / label for field 39 TFORM39 = '1E ' / data format of field: 4-byte REAL TTYPE40 = 'SAREA ' / label for field 40 TFORM40 = '1E ' / data format of field: 4-byte REAL TUNIT40 = 'pixel ' / physical unit of field TDISP1 = 'F7.2 ' / display format TDISP2 = 'G10.3 ' / display format TDISP3 = 'G10.3 ' / display format TDISP4 = 'G9.3 ' / display format TDISP5 = 'G9.3 ' / display format TDISP6 = 'F6.4 ' / display format TDISP7 = 'F6.4 ' / display format TDISP8 = 'F6.2 ' / display format TDISP9 = 'F6.2 ' / display format TDISP10 = 'F7.2 ' / display format TDISP11 = 'F6.2 ' / display format TDISP12 = 'F7.2 ' / display format TDISP13 = 'F6.2 ' / display format TDISP14 = 'G8.3 ' / display format TDISP15 = 'G6.3 ' / display format TDISP16 = 'G6.3 ' / display format TDISP17 = 'F7.5 ' / display format TDISP18 = 'G7.3 ' / display format TDISP19 = 'G7.3 ' / display format TDISP20 = 'G7.3 ' / display format TDISP21 = 'G12.5 ' / display format TDISP22 = 'G12.5 ' / display format TDISP23 = 'G7.3 ' / display format TDISP24 = 'G7.3 ' / display format TDISP25 = 'I6 ' / display format TNULL25 = -2147483647 / undefined value for column TDISP26 = 'I6 ' / display format TNULL26 = -2147483647 / undefined value for column TDISP27 = 'G9.3 ' / display format TDISP28 = 'G7.3 ' / display format TDISP29 = 'G9.3 ' / display format TDISP30 = 'G7.3 ' / display format TDISP31 = 'G9.3 ' / display format TDISP32 = 'G7.3 ' / display format TDISP33 = 'G9.3 ' / display format TDISP34 = 'G7.3 ' / display format TDISP35 = 'I5 ' / display format TNULL35 = -2147483647 / undefined value for column TDISP36 = 'I5 ' / display format TNULL36 = -2147483647 / undefined value for column TDISP37 = 'I3 ' / display format TNULL37 = -2147483647 / undefined value for column TDISP38 = 'I2 ' / display format TNULL38 = -2147483647 / undefined value for column TDISP39 = 'G9.3 ' / display format TDISP40 = 'F5.1 ' / display format IMAGE = 'synth.fits' END F’*˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙C€€˙˙˙˙C€~z˙˙˙˙Ãvô˙˙˙˙˙˙˙˙ÁÅ+˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙?__F‘^A’˜ÃBēßųB„#ø>“ü7>)ěÂ=ũœAžė›C€€=]?€C€~z=W‹(ĨPD R$?‰0?Y~`Á‡Œ;‹; ÉF’*F’*ÁÅ+ÁÅ+ŧ× h=äØģ‹zĢ=ÔVŊ•O.=§—Ôž °>­#^ =s z@?ĩĩF%˛Aąq™Bâ2BŸō>“ü7>)Ā Â=ū AžčUC€€=sYC€~T=mÄ ŠÍD$P?‰•å?^ŧüÁz€;,wŽ;,üF’*F’*ÁÅ+ÁÅ+ŧ× a=ä+ģË =ÔSPŊ•UĒ=§•Ģž Ģ5>­ =Šƒf@?!azFŖgAÖ´qCŲBÁˆG>“ü7>)ĘBÂ>ĨAžņæC€€=…āC€~*=‚jÄ­D'Zų?‰š[?dųÁjŠ;Q|;PäcF’*F’*ÁÅ+ÁÅ+ŧÖSû=ä5ģŽQž=Ô`,Ŋ•­˙=§÷ž ˛!>­" =ʎ@?1„ĶFŋBŲC%†&Bę>“ü7>)ŋÂ>üAžįkC€€=“9ųC€}ú=kŌÄ+D\D87?‰G?iœÁWk;~‘s;}­PF’*F’*ÁÅ+ÁÅ+ŧ֞j=ä‘ģŽ˛:=ÔQëŊ•›ī=§ÖĀž ¨Z>­ Ū =ī+ŋ@?CEOF G B)#CHWcC ŠŠ>“ü7>)ÉåÂ>īAžņC€ū=ĄũĩC€}É=ÍÄ6Á@;šî2;šDoF’*F’*ÁÅ+ÁÅ+ŧÕuS=ä ģ_=Ô`TŊ•Āã=¨ æž ą/>­"4 > U@?VĖ>F `B>?CrY\C+]Ū>“ü7>)ˇœÂ=ųŪAžāpC€€=˛ÁC€}‹=­„āÄO@ĮD^ˇz?‰Œį?uÁ#‹;ŧŗš;ģģŦF’*F’*ÁÅ+ÁÅ+ŧ×^Î=äÖģŒë¨=ÔF(Ŋ•)=§^üž Ŗž>­Š >6™‘@?lGF HĐBfbC’ĸ—CO_•>“ü7>)ÃēÂ=˙EAžëČC€€=ÃųC€}N=žë5ÄcīDuQ?‰–6?zë’ÁØ;æG‰;äŅÆF’*F’*ÁÅ+ÁÅ+ŧ× )=äËģ•|=ÔV`Ŋ•l =§­Đž ­Í>­< >;t@?ķãF öũB‹6ˆCąvdCzøF>“ü7>)Į{Â>ÔAžīLC€ū=טĪC€} =ŌĐÄzŋûD†Æá?‰™H?€|CÁփ< Ë < ŦôF’*G5ö´ÁÅ+Á:ēŸŧÕø=äķģ´=Ô\TŊ•ĸŊ=§ęģž ¯ô>­u >jöq@?Žō­FXãB¨ČĒC×(tC˜#Ã>“IÕ=ë7ÁÂ=dĮA]3C€ö=ŖīÄC€|ŧ=ŸûLÄĮŨzDŽY’?6T¯?ƒ•kÁĸ<,á-<+3”F’*G5ö´ÁÅ+Á:ēŸŧ—rb=ší×ģ]ĸk=’ŧÎŊEú)=N$ž^c >4€ ?Öũ@?>%FąBĻÎ{CÔŖ2C–[>wcA=… Â7'eAūC€€Ô=BoC€~ļ=A'KÅ(éŪD`|r>Ē­?†Á˛Áĩ<0t}<.ŧŦG5ö´G5ö´Á:ēŸÁ:ēŸŧ,‹Ô="šĀģ´ĩI=āßŧˇo5SDŠ=(Î*Â;Ô @Ķ+IC€€˙=ZC€Z=–ÅHŖ}Dčß>JÁÆ?АÁ,]<ū~<ĖÖG5ö´G5ö´Á:ēŸÁ:ēŸģ‡ T<ĨWģ‹N<Ŋ|ŧ€:6<+¸<Ŋ….Œ<ƒTĪ > Ę1@?žC„Eëü;BNxdCƒ™xC:>Géų<íēŋÂ:d@œ,ŌC€:<ËôC€R<ČõRÅMV$CãB> ĒM?U}Á;ô";ōYĶGrâ‡G’ŽÁ?žáÁC‘ ģ!ē<ˆĻņģņ<ƒ[ŧŧRį\< ÅĩŊ-wĶ<#} ={Ō@?ŅJEEÜ6MB*Á,CYĢ”Cę…>C$ô<ˇŲ~Â9ī+@vĶ3C€€č<Ŧ >C€(<ĒušÅHŞCĨËí=ĶgB?ŊöÁën;Ø-Ž;ÖßyGrâ‡G’ŽÁ?žáÁC‘ ģo‘™<ũi<„^Â8ōĮ@6‚˙C€&<‡A`C€S<†C=Å=šųCPĒÃ=ŒŪG?”;xÁ‚Ė;­Ė;Ŧŋ‰G’ŽG’ŽÁC‘ÁC‘ 쀿€<ĄgēËj<Ōŧ ‘B;°uŧŦ{Ž;­đB =Âũ@?ũ=ĩEš@A’ÍBģ"ˇB„S*>5K<ˇIÂ7Β?Öŋ•C€<<'ĶC€Ņ<&ĒÅ2ČC\Ë=O|Ą?—Î…ÁæÚ;\Ėh;\^G’ŽG’ŽÁC‘ÁC‘ ģžÍą;˜b:;—5TģŊYĩ;‡>Uģ˛1; =L\@@ HWEĻ:āAĒBØČB™Iū>*ũ;áËG’ŽGģ+ŋÁC‘ÁGBĨ ŧW<;Ûã3ēęĀe;Ų_qēmåj;†đ<‡Ą^;ˆD @u`˜@@5úE˜úŸAūôBKˇĢB Ė>5 ;×[ņÂįXē@@(ˆ-EŒãÕA-5˙Bm-ĮB'ĩô>?ņ<Â1Ûę?ާ˜C€€.<@ĸwC€€üCŽ#@@9b˜EËė@ôR1B2úAû×#>Dœ;Ÿę(Â6X§?WXŽC€é<’ C€€f<:ČÄŽĸˇAūmŧ<ē|?ĻüÆÁŧ;ęŌ;¯įH Ē3H?QÁN5ÁNåv9Îŋ™;=xÂēŗ;5 ēŠ:K;˙ģÍÃ;3 >ŨH@@KėtEiĐā@}AÔ;ŦA–K>AM|;Y øÂ3 Ÿ?ĖâC€ũ;ÁŦ?C€ų;Áí‹Ä‰ A´8<¨K?Ģ›Áëv:¨[ :¨ ŊH Ē3H9´ÁN5ÁSI%¸Ŗâš:ųÛu¸¨ĸ:ų~Í:ŊŅ::į˜;T§:æė& =ŋŲį@@`PæESĖĻ@Ą'mAūΎA´,ø>AËą;‹Ą(Â8ļˆ?97rC€€š< =GC€ų< gmÄW˙A’ <­M?¯#KÁ 3¨:Ķ—):ĶcH,ëÃH9´ÁQíÁSI!%ēy]Í;#EÕšƒ‰;!C`ēK(t;āĐ;Žãf;, >Lėß@@vŋdEAC@'ĸjAŠūÁAD‘>F ;M Â4ŧK>ŋÃ)C€ē; ÅąC€ũ; žiÄ=ĐøAģJF÷o:Ö­āÂ18>ŠkZC€€;€!ßC€€;€žLÄ&ŧ@Ųf˛<'Í?ˇ¯§Á âŽ:<°v:<ËHEãŽHn$ÁTDÔÁW|J)9:P(:}}¸ 6ų:{ĩ<:cŌ(:j*ķēŊF´:h–H =U@@•HFE`@ qAld_A''‰>G˛Æ; ;ŽÂ3îl>°[@C€ü;ļũC€€;ļžÄ…ô@ĸßŨ<"6?ŧ‘Á Đ:gÅė:gf™HtDKH…”ÁWí4ÁYjŽ;E9ėŽ:Ŗ÷EšĀˇÁ:Ĩ3Ÿ7"B°:”Ēö;'‹:•І2?S€@@¤5įE–œ?Š™Ađé@ē—Ä>I­7:”v™Â36 >=>ĀC€ō;W*ũC€>;WfÚÃä-@HIp:ŒTFÂ2ëĨ>4\ôC€€S;_ĢzC€€I;_ūŌÃļ×Ü@ ž;Å Ũ?ÅLÁn'9ū]Ē9ūmęH“¸5H e˜Á[;Á\Š9Sa9´ô:)fš×*:'^žšFß:3:˛ÄŸ:‡u =8ä‡@@ÆąŪDæ6ú?$}ņ@ŦĄ@sIq>JdÂ:R”fÂ49Ą>ƒ C€€ĸ;8¯wC€ē;8 šÃ›Vk? ē;„q5?ĘÁ›ã9Æßu9ÆWęHĄįH´^Á\ēÁ^ŗ ayē2Ë;9÷ø˜8=>Ĩ9ö¯č¸4į`9æžē‚# 9ää3# <=ē@@ڐuDÍũÕ>ČsÛ@]K+@zm>I¸: ÜfÂ3žÉ=ļŽ?C€€+; yđC€Ŋ; …ŽÃ}æP?;R0;<Ūá?ÎíSÁ­u9†ū‚9‡uĖHŗĖHÆgãÁ^žÁ`Zúw‘ēLô9ĻĻ’š=ā9ŖÂˇš‹9ĄMc9öN9ž‹u' ;Ú71@@đk´D¸{i>^Ŗ_?˙f?´[‡>JëŠ9Š&JÂ4’ĩ=TK‚C€å:˛ųĸC€ų:˛Ķ7ÃRnÛ?ÖĖ;ŧÜ?ĶęĩĀũ†9'ž?9'ĖNHÂÖųHÛ´=Á` QÁb q‹ą9‘ā9KŨ`7M°9Lxũ¸Ū¨Ĩ9@x;šĘ9A * ‰€A@$۟?é$ī>KeW9æ26Â3“Ļ=‘˛C€€;Ē[C€×;ĀÃ.B>Ė; W?ŲåĀų™,9hŪ9gāHŨÎŪHõzĀÁbJÔÁd ÆŗŨš¤5B9‰GŠ7mZ9Š> 9Œw˙9ōaš˙˜d9‚čŸ. ;r{ö@At]D’ĀŠ>;|+?ė°1?§]&>K 9¯PMÂ3ā‘=\´C€é:ā¸gC€ĩ:ā‰à °Ī>ƒģ4:î~?ŪB Āõ’î91'91žØHđËKI(äÁc¸Áex%Ķšg˙9PÜ9='9OŪhĩpŧ9IÁÖ9Žž9I˛/3 ;*Ią@A D‚ŦX>d–?Á¨Ũ?ˆđ#>KFˇ9—˙Â4ķ==ĄC€đ:ÔÖÖC€€:Ô՞Âč€>:Z:ͅE?㞊ĀņŠÉ9ÎH9ЉIĨIFáÁeECÁg÷ũ=8q-98ĪôˇãšQ98ūĪ67ŖG947b9”ą94w8:‰JØ@A0Dhh„=Ų&?—!?Uē–>K°M9tÄ/Â4C&=kßC€€:ž`œC€€:žNžÂŊŠ =ûCã:Š“š?éČĀíw9°ķ9ú°IŦYIÍåÁfĘÁh‡e/y86-Î9s§¸įÍ'9•Ĩ¸„Š9ÕÛ8šŋŠ9L> ;;k@AA™šDNXy=¤ÂÍ?p-¤?)Ôę>KÅú9PˆÂ44=ĐdC€€ :ąģC€˙:ąĩ#™ČĶ=§õ„:‹ĖH?îēÉĀéU8Ũŧ8ŪwI÷¯I/9ÁhS¨Áj8°mҏ™@9]íˇ“§:8˙ũŨ¸Ÿ‰Á8ø#ŋ9{ߛ8÷ƒŽD :đß^@ATõÃD7{=lˇE?3ü_>ū‰Ô>KÖ|9.#WÂ4)r<؛C€€5:ĸæ…C€ë:ĸÜUÂyBz=Ph}:V 7?ô|}Āå-Õ8ŗV8´I,) I=PÁiî”Ák™hŊ)ši8ŌŊaˇŪf8Ôh7¸"9-8Éū9{Tņ8ËŦŸJ :psÔ@AjAŠD"bŦ=”/>×ng>˜U9>Lô8×FcÂ47<†ĖũC€÷:^F8C€ü:^CåÂIĶ<éÂ:°?úaēĀá18e„)8fë„I˛Ã˛>|Τ>LMX8Ŧ(ĮÂ4ˆ”é´>R˜;>LDĸ8ēÜŪÂ4ßYëã>ū>L¤8~ķXÂ4<(ęC€Ų:/*đC€ü:/(ãÁĪÄt<uE9ļė@wĢĀԃÅ8ņŅ8pIqÁtI…j¨ÁoÔXÁq‹Cĩą8—KĶ8mˇ:58M ¸V:8/o¸¸”Ŗ8rÂm :LI}@AĢ|ŗCÉ`F;é§ū=â<–=Ÿų6>L‰8Q”QÂ49<ípC€ü:vBC€€:rÁÁ§ ?#i°<ú`ŋ@ ĩĀĀĐg-7ĸ7 ęI„5{Iœ^ÁqbĶÁrņ’Š4čŦ°8îˇY˜8‘ļŠŖŋ7ûYG¸ąˇĖ7üMx 9Úc@AŧĸÅCŗ2w;æI?=éÛN=Ĩ\”>L…m8@Â4†;ģŧÅC€õ9ųę"C€ô9ųé”Á„Û%>Ô:™<ĖxÁ@ ŲĀĖYP7°ĩč7´ |IIœĩ%ÁrÂ~ÁtVÖ{Õ5Į´7ē‘=ļCc7ēŠíˇRãF7ēHÄ6Ŗ“ž7ēo[„ : -@AĪŲCŸâJ;ė*4=û[â=ąŧĶ>L 8÷Â3ūĀ;ŗ‘kC€Ü:ĸ’C€ß:ĸĪÁKes>”tF<ēŲ2@nrĀČc7Ë&O7Ī8I›ßIŠ*tÁt'ÁuĢ -ļ l¯7˛\~7ya7˛´ģ6ž„Ė7ŽKЏ_éŌ7Žų'‘ 9“ŨŲ@Aä?ÕCä>LŠ~7ÛqžÂ4´;ˆg5C€€9Üi¯C€ô9ÜiÁ#Ęæ>JķÉ<žš6@ę Āćs´,âu4,âuI¨I¸[ÎÁuŒrÁw)‚ũ ˇQZA7‡šļ$l'7ˆB>6iŗ¨7ƒ–ϏLî7„†&Ÿ 9M–@AûC€‡;f¤š=†×=>˛>LĢ7´ÛwÂ3ũŗ;`ö˜C€ķ9ĮßCC€ō9ĮßōÁ Ú> E<‰Ō@{ ĀĀÍY7|U7ub IļXÆIĮãdÁvøļÁx‘- ¯ ĩĪBd7`.Lļˆˆ7a(Ö6‰Ę7[Ąũ¸Xé7\㯠9HĶÖ@B BCgôĄ;LË´=zŌ=1[š>LĒ7‹ķ¯Â4;-žŪC€€9Šī1C€û9Šî$ĀÆcņ=Ŧč<^@";ĀŊ;~7xY'7rōĻIÅëŨIŲ"ôÁxe3ÁzB ģĄˇĒĨ7*čˇ2•7,Îļ™!Ô7*qO7ĀT7+ĩ5Ā 8…ą@BæbCRi°>L°Į7_îÂ3ūË; lČC€€9•Í×C€€ 9•ΚYŒ=€ËoLŧ7;ÆÂÂ3ũ—:閊C€ü9Š!ŪC€ũ9Š"\Āpã°=īō<(č<@"´ŖĀļŠÍ6ŧÉ[6ÁfËIéÎJqÁ{Lŧ­7)ŦÂ3ũ]:ļØ{C€ū9n C€˙9n Ā7ö<Ō×ŋ<ŗß@&Ą Āŗŗ6Ę&Š6ÉoƒIũ™âJ .Á|ŗŸÁ~n$ĢÕ6Œ¤S6ļQŋļJĄm6ˇCÅ6žP6ŗ•Oˇmģ6´ĄJ 8ZÊ@BJ-ČC#o:Lã~<˜¯Lžč6ŪÃEÂ4¸:Š•‡C€ö9FNŽC€ķ9FNxĀBD<Ŧ9<Cg@*Ĩ¨Ā°ú6Žđu6ŦæūJ aŠJz˜Á~8IÁ€f ]5Ÿė‡6Špõ5­SĘ6ŠöEĩįķ*6‰ ˇir6‰ˇ 8kT@B^eCĘ$:{ŸÚ<ÃË.<Šrq>LŋŨ6ļ҆Â3˙:cPÚC€€92ø“C€ų92øąŋŲ&ŋ>LÂã6˜ōüÂ4°:>ģzC€ü9$ėC€û9$ëķŋ¤×<\č;ČII@2ųįĀŦEå3“nˆŗ“nˆJ&.J9€GÁ€ŗ2Á§Ä$Ë-ņļŋR6=m´[Į56=ā„ĩŠÂŗ6=UÃļ^6=¯§V 7ģš@B†ŒĪC~>LÃá6pEæÂ3˙Ņ:ģUC€ū9v%C€€9v/ŋw’”;¤Žß;ĒIø@7JžĀĒKž2áΞáĪJ6Š„JM8{Áƒ˙Á‚ˆg,_7Š6˛ã6 ļ}Ņ6YĩWŧ6ül4Ũĸ;6VQx 7r×@B”JB˙âh:„oÍ<íä*<¨6ë>LÄđ6RÂ3˙ō:ģĪC€€9đTC€€9đWŋ;é;Yė4;•@;ļ9Ĩ7ģ}7 JIŦ}Jc†ÜÁ‚aŖÁƒmÖ5ˇC)3•dX6Íß4§)C6A?4dĻP6Ɖ4YY˜6FĖ 7Ū}@BĸÎ8Bõ ô9Ī‘LÆY6õAÂ4e9Æ ĻC€˙8äŪC€€8äŧŋ |;Ă;‚ũĀ@@<ũ§•6zŗ46])ŠJ_ĪįJ~ĩÁƒI<Á„cA Qiŗw15Æ/ŋĩ‰Ä…5Əö4„65ÂĮ ļÁ´D5Ã>ŨĮ 7@y4@Bŗ Bė˜>LĮ76 ĪÔÂ3˙Ņ9̘gC€˙8ŲmC€ü8ŲmŸžŅƒÃ:ˇøí;`Ęb@D߲ĀĨĀ3ÔxwŗÔxwJy‰-JŽetÁ„;'Á…aNŋbiĩRr5̃ŠĩÉj 5ŦōĩŽéč5Ģ 85­Ôë5̝ßô 7eMŋ@BÄūĻBäÍÚ>LČ5Üw+Â4"9‰@ûC€€8ŋNûC€˙8ŋNņžš…œ:hÃe;@Īį@IŸ¤Ŧē4yLÍ´yLÍJ‹ĪÃJ Ŧ_Á…8IÁ†m‰_Uw=˛.Ž5‰bëĩæ5‰ČĢ´ū'5ˆ‡h60Ô÷5ˆúč& 6Ø| @BØąˇBŪŲ>LČŋ5Ŋ9ßÂ49k›ŋC€€8´žûC€˙8´žøžc=h:XL;$Ũã@N{ĻĀŖÂ4HR´HRJcĄJļ !Á†?Á‡ƒ'sG)5Zl5kz>ĩ’īē5l(‰´…ƒ5jîÆ5šžĀ5k´] 6Ļ$Ā@Bî]BÚŋ>LÉĘ5šQ6Â49?ūbC€˙8ĄųŋC€€8ĄųŊž&Ř9Ŋũ¸;Ō5@SvJĀĸ˙iŗ¯u3¯uJ˛)&JĪdEÁ‡SCÁˆĨ‹kŽM4¯d;5@!Ũĩ=âÛ5@Õ ŗųëč5@ Ļ4ƀœ5@͍™ 6N.@C™BÖ":.ēú<Đė <“ģW>LĘa5„Â49$_ØC€ū8˜|C€ũ8˜|Ŋķ‡9zä;Ūy@XĒĀĸ_d6éÅQ6ŲīmJĘáwJíÅzÁˆt-Á‰Õ ¨šŌũ3Ah5$YŌĩ‰dÚ5$ÖR09 5#į_ĩž?^5$s§Ü 60Ø@C5ÂBĶt>LĘŖ5^.­Â49 EüC€ū8‹C€˙8‰Ŋ¯øy9"ŗ:ŨS6@]Č…ĀĄŨ4ĐĶ´ĐĶJčQKö€Á‰ĄtÁ‹ÉĖ1˙5ŗŧuU5 tdĩK5 åé4ŒQ5 å{ĩ¨Šđ5 dI% 6>ŲP@CĄ‰BЊÆ9Ņ}O<‰ļ LË5B™æÂ48ō¯C€€8‡íNC€˙8‡íMŊ}Tæ8Ÿŧū:Ąkš@c!ĀĄsū6‡îI6KKˇ=K¨NÁŠÚoÁŒVŧ÷4Ņ´q}°4ō>4ĩ0BÜ4ķ'ų´â™4ņФĩmđ4ōĒžu 5͚[@C.~}BΔz9qįŌ<&Ķ;ëėũ>LË5#ûÂ3˙û8˞C€˙8| 7C€ū8| :Ŋ5ƒ8–n:Ô@ą@h›¸ĀĄę6*ŸÂ6JNKąēK8´ˆÁŒ|Á¨Ā*Ķuĩ1‰nĘ4ĖNŨĩx 4Ėų2”ô^4Ėí5āĻ4ĖÔ×Î 5ĢŠ§@C?ņŠBÍ m9-CI;ú—@;ą1É>LË}5ÖÂ48˛ķzC€ũ8s%ņC€ū8s%ņŊ§8^#:Ũ`!@n7ĸĀ Ũ95÷5åœuK3öKWøļÁnåÁ}iŗÄ43rŅ4ŗOų´5z4ŗönŗ—ˆ24˛ã}ĩ@ī4ŗ›L/ 4ÕP~@CS#KBËÔŽ>L˒5ČWÂ48 5tC€˙8ovUC€€8ovTŧĩÂ[@sö-Ā ¨Āŗ„33„3KROLK}ŦXÁŽÉkÁjCĩš#4cžķ4 u‚´´ĘĐ4Ą éŗZå”4 løŗĒ4Ą˙š 5eˆ@Ch@lBĘäõ>LËÆ4ä*šÂ3˙ũ8ņ×C€˙8i\C€ū8i\ŧ~Ķr7¯˙×:°Īl@yØ-Ā Ė4û´ûKvĘŲK•”hÁ-Á‘Ų—–)´“E94Žā´ÁĶM4ŽĄ/´ŗ,4Ž ­´h0A4Ž›Ä 4&ll@CzBĘ,:]ā<ãë< Ũ×>LĖ4ОoÂ3˙ũ8ēNC€˙8j¨ÉC€ū8j¨Ęŧ1bN7ĒÎĩ:ö‚=@Ū~Ā `6ÁĢl6´GnK‘dmK°ķPÁ‘™ŨÁ“N§€Ņ! ´š14ëj´›n04‚oą˛dú~4×X´ 14‚hG‘ 4i(˙@CŒƒ#Bɞm>LĖ4ŧĘâÂ3˙ú8qkC€˙8lŲ@C€ū8lŲBģõŒH7vũ;ĀD@ƒĀ G´˛ÖZ’2ÖZ’KĢéLĖ5üÂ3˙ú9ÄVC€˙8ņk„C€ū8ņk‹ģ¨=@†-ĖĀ 5ŗ† 3† KÁēKĶ›(Á”ßÁ”Ü[rŸŅcŗĖ#4ôĩ´á:Ē4ĩgO´lc&4Ī,ŗĩ˜c4Ę\,‘5„›@././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/tests/data/synth_table_mean.fits0000644000175100001660000023540014755160622025330 0ustar00runnerdockerSIMPLE = T / file does conform to FITS standard BITPIX = 16 / number of bits per data pixel NAXIS = 0 / number of data axes EXTEND = T / There may be standard extensions COMMENT FITS (Flexible Image Transport System) format is defined in 'AstronomyCOMMENT and Astrophysics', volume 376, page 359; bibcode: 2001A&A...376..359H ORIGIN = 'STScI-STSDAS/TABLES' / Tables version 2002-02-22 FILENAME= 'synth_table.fits' / name of file NEXTEND = 3 / number of extensions in file END XTENSION= 'BINTABLE' / binary table extension BITPIX = 8 / 8-bit bytes NAXIS = 2 / 2-dimensional binary table NAXIS1 = 160 / width of table in bytes NAXIS2 = 69 PCOUNT = 0 / size of special data area GCOUNT = 1 / one data group (required keyword) TFIELDS = 40 TTYPE1 = 'SMA ' / label for field 1 TFORM1 = '1E ' / data format of field: 4-byte REAL TUNIT1 = 'pixel ' / physical unit of field TTYPE2 = 'INTENS ' / label for field 2 TFORM2 = '1E ' / data format of field: 4-byte REAL TTYPE3 = 'INT_ERR ' / label for field 3 TFORM3 = '1E ' / data format of field: 4-byte REAL TTYPE4 = 'PIX_VAR ' / label for field 4 TFORM4 = '1E ' / data format of field: 4-byte REAL TTYPE5 = 'RMS ' / label for field 5 TFORM5 = '1E ' / data format of field: 4-byte REAL TTYPE6 = 'ELLIP ' / label for field 6 TFORM6 = '1E ' / data format of field: 4-byte REAL TTYPE7 = 'ELLIP_ERR' / label for field 7 TFORM7 = '1E ' / data format of field: 4-byte REAL TTYPE8 = 'PA ' / label for field 8 TFORM8 = '1E ' / data format of field: 4-byte REAL TUNIT8 = 'deg ' / physical unit of field TTYPE9 = 'PA_ERR ' / label for field 9 TFORM9 = '1E ' / data format of field: 4-byte REAL TUNIT9 = 'deg ' / physical unit of field TTYPE10 = 'X0 ' / label for field 10 TFORM10 = '1E ' / data format of field: 4-byte REAL TUNIT10 = 'pixel ' / physical unit of field TTYPE11 = 'X0_ERR ' / label for field 11 TFORM11 = '1E ' / data format of field: 4-byte REAL TUNIT11 = 'pixel ' / physical unit of field TTYPE12 = 'Y0 ' / label for field 12 TFORM12 = '1E ' / data format of field: 4-byte REAL TUNIT12 = 'pixel ' / physical unit of field TTYPE13 = 'Y0_ERR ' / label for field 13 TFORM13 = '1E ' / data format of field: 4-byte REAL TUNIT13 = 'pixel ' / physical unit of field TTYPE14 = 'GRAD ' / label for field 14 TFORM14 = '1E ' / data format of field: 4-byte REAL TTYPE15 = 'GRAD_ERR' / label for field 15 TFORM15 = '1E ' / data format of field: 4-byte REAL TTYPE16 = 'GRAD_R_ERR' / label for field 16 TFORM16 = '1E ' / data format of field: 4-byte REAL TTYPE17 = 'RSMA ' / label for field 17 TFORM17 = '1E ' / data format of field: 4-byte REAL TUNIT17 = 'pix(1/4)' / physical unit of field TTYPE18 = 'MAG ' / label for field 18 TFORM18 = '1E ' / data format of field: 4-byte REAL TTYPE19 = 'MAG_LERR' / label for field 19 TFORM19 = '1E ' / data format of field: 4-byte REAL TTYPE20 = 'MAG_UERR' / label for field 20 TFORM20 = '1E ' / data format of field: 4-byte REAL TTYPE21 = 'TFLUX_E ' / label for field 21 TFORM21 = '1E ' / data format of field: 4-byte REAL TTYPE22 = 'TFLUX_C ' / label for field 22 TFORM22 = '1E ' / data format of field: 4-byte REAL TTYPE23 = 'TMAG_E ' / label for field 23 TFORM23 = '1E ' / data format of field: 4-byte REAL TTYPE24 = 'TMAG_C ' / label for field 24 TFORM24 = '1E ' / data format of field: 4-byte REAL TTYPE25 = 'NPIX_E ' / label for field 25 TFORM25 = '1J ' / data format of field: 4-byte INTEGER TTYPE26 = 'NPIX_C ' / label for field 26 TFORM26 = '1J ' / data format of field: 4-byte INTEGER TTYPE27 = 'A3 ' / label for field 27 TFORM27 = '1E ' / data format of field: 4-byte REAL TTYPE28 = 'A3_ERR ' / label for field 28 TFORM28 = '1E ' / data format of field: 4-byte REAL TTYPE29 = 'B3 ' / label for field 29 TFORM29 = '1E ' / data format of field: 4-byte REAL TTYPE30 = 'B3_ERR ' / label for field 30 TFORM30 = '1E ' / data format of field: 4-byte REAL TTYPE31 = 'A4 ' / label for field 31 TFORM31 = '1E ' / data format of field: 4-byte REAL TTYPE32 = 'A4_ERR ' / label for field 32 TFORM32 = '1E ' / data format of field: 4-byte REAL TTYPE33 = 'B4 ' / label for field 33 TFORM33 = '1E ' / data format of field: 4-byte REAL TTYPE34 = 'B4_ERR ' / label for field 34 TFORM34 = '1E ' / data format of field: 4-byte REAL TTYPE35 = 'NDATA ' / label for field 35 TFORM35 = '1J ' / data format of field: 4-byte INTEGER TTYPE36 = 'NFLAG ' / label for field 36 TFORM36 = '1J ' / data format of field: 4-byte INTEGER TTYPE37 = 'NITER ' / label for field 37 TFORM37 = '1J ' / data format of field: 4-byte INTEGER TTYPE38 = 'STOP ' / label for field 38 TFORM38 = '1J ' / data format of field: 4-byte INTEGER TTYPE39 = 'A_BIG ' / label for field 39 TFORM39 = '1E ' / data format of field: 4-byte REAL TTYPE40 = 'SAREA ' / label for field 40 TFORM40 = '1E ' / data format of field: 4-byte REAL TUNIT40 = 'pixel ' / physical unit of field TDISP1 = 'F7.2 ' / display format TDISP2 = 'G10.3 ' / display format TDISP3 = 'G10.3 ' / display format TDISP4 = 'G9.3 ' / display format TDISP5 = 'G9.3 ' / display format TDISP6 = 'F6.4 ' / display format TDISP7 = 'F6.4 ' / display format TDISP8 = 'F6.2 ' / display format TDISP9 = 'F6.2 ' / display format TDISP10 = 'F7.2 ' / display format TDISP11 = 'F6.2 ' / display format TDISP12 = 'F7.2 ' / display format TDISP13 = 'F6.2 ' / display format TDISP14 = 'G8.3 ' / display format TDISP15 = 'G6.3 ' / display format TDISP16 = 'G6.3 ' / display format TDISP17 = 'F7.5 ' / display format TDISP18 = 'G7.3 ' / display format TDISP19 = 'G7.3 ' / display format TDISP20 = 'G7.3 ' / display format TDISP21 = 'G12.5 ' / display format TDISP22 = 'G12.5 ' / display format TDISP23 = 'G7.3 ' / display format TDISP24 = 'G7.3 ' / display format TDISP25 = 'I6 ' / display format TNULL25 = -2147483647 / undefined value for column TDISP26 = 'I6 ' / display format TNULL26 = -2147483647 / undefined value for column TDISP27 = 'G9.3 ' / display format TDISP28 = 'G7.3 ' / display format TDISP29 = 'G9.3 ' / display format TDISP30 = 'G7.3 ' / display format TDISP31 = 'G9.3 ' / display format TDISP32 = 'G7.3 ' / display format TDISP33 = 'G9.3 ' / display format TDISP34 = 'G7.3 ' / display format TDISP35 = 'I5 ' / display format TNULL35 = -2147483647 / undefined value for column TDISP36 = 'I5 ' / display format TNULL36 = -2147483647 / undefined value for column TDISP37 = 'I3 ' / display format TNULL37 = -2147483647 / undefined value for column TDISP38 = 'I2 ' / display format TNULL38 = -2147483647 / undefined value for column TDISP39 = 'G9.3 ' / display format TDISP40 = 'F5.1 ' / display format IMAGE = 'synth.fits' END F’*˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙C€€˙˙˙˙C€~z˙˙˙˙Ãvô˙˙˙˙˙˙˙˙ÁÅ+˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙?__F‘^A’˜ÃBēßųB„#ø>“ü7>)ěÂ=ũœAžė›C€€=]?€C€~z=W‹(ĨPD R$?‰0?Y~`Á‡Œ;‹; ÉF’*F’*ÁÅ+ÁÅ+ŧ× h=äØģ‹zĢ=ÔVŊ•O.=§—Ôž °>­#^ =s z@?ĩĩF%˛Aąq™Bâ2BŸō>“ü7>)Ā Â=ū AžčUC€€=sYC€~T=mÄ ŠÍD$P?‰•å?^ŧüÁz€;,wŽ;,üF’*F’*ÁÅ+ÁÅ+ŧ× a=ä+ģË =ÔSPŊ•UĒ=§•Ģž Ģ5>­ =Šƒf@?!azFŖgAÖ´qCŲBÁˆG>“ü7>)ĘBÂ>ĨAžņæC€€=…āC€~*=‚jÄ­D'Zų?‰š[?dųÁjŠ;Q|;PäcF’*F’*ÁÅ+ÁÅ+ŧÖSû=ä5ģŽQž=Ô`,Ŋ•­˙=§÷ž ˛!>­" =ʎ@?1„ĶFŋBŲC%†&Bę>“ü7>)ŋÂ>üAžįkC€€=“9ųC€}ú=kŌÄ+D\D87?‰G?iœÁWk;~‘s;}­PF’*F’*ÁÅ+ÁÅ+ŧ֞j=ä‘ģŽ˛:=ÔQëŊ•›ī=§ÖĀž ¨Z>­ Ū =ī+ŋ@?CEOF G B)#CHWcC ŠŠ>“ü7>)ÉåÂ>īAžņC€ū=ĄũĩC€}É=ÍÄ6Á@;šî2;šDoF’*F’*ÁÅ+ÁÅ+ŧÕuS=ä ģ_=Ô`TŊ•Āã=¨ æž ą/>­"4 > U@?VĖ>F `B>?CrY\C+]Ū>“ü7>)ˇœÂ=ųŪAžāpC€€=˛ÁC€}‹=­„āÄO@ĮD^ˇz?‰Œį?uÁ#‹;ŧŗš;ģģŦF’*F’*ÁÅ+ÁÅ+ŧ×^Î=äÖģŒë¨=ÔF(Ŋ•)=§^üž Ŗž>­Š >6™‘@?lGF HĐBfbC’ĸ—CO_•>“ü7>)ÃēÂ=˙EAžëČC€€=ÃųC€}N=žë5ÄcīDuQ?‰–6?zë’ÁØ;æG‰;äŅÆF’*F’*ÁÅ+ÁÅ+ŧ× )=äËģ•|=ÔV`Ŋ•l =§­Đž ­Í>­< >;t@?ķãF öũB‹6ˆCąvdCzøF>“ü7>)Į{Â>ÔAžīLC€ū=טĪC€} =ŌĐÄzŋûD†Æá?‰™H?€|CÁփ< Ë < ŦôF’*G5ö´ÁÅ+Á:ēŸŧÕø=äķģ´=Ô\TŊ•ĸŊ=§ęģž ¯ô>­u >jöq@?Žō­FXãB¨ČĒC×(tC˜#Ã>“IÕ=ë7ÁÂ=dĮA]3C€ö=ŖīÄC€|ŧ=ŸûLÄĮŨzDŽY’?6T¯?ƒ•kÁĸ<,á-<+3”F’*G5ö´ÁÅ+Á:ēŸŧ—rb=ší×ģ]ĸk=’ŧÎŊEú)=N$ž^c >4€ ?Öũ@?>%FąBĻÎ{CÔŖ2C–[>wcA=… Â7'eAūC€€Ô=BoC€~ļ=A'KÅ(éŪD`|r>Ē­?†Á˛Áĩ<0t}<.ŧŦG5ö´G5ö´Á:ēŸÁ:ēŸŧ,‹Ô="šĀģ´ĩI=āßŧˇo5SDŠ=(Î*Â;Ô @Ķ+IC€€˙=ZC€Z=–ÅHŖ}Dčß>JÁÆ?АÁ,]<ū~<ĖÖG5ö´G5ö´Á:ēŸÁ:ēŸģ‡ T<ĨWģ‹N<Ŋ|ŧ€:6<+¸<Ŋ….Œ<ƒTĪ > Ę1@?žC„Eëü;BNxdCƒ™xC:>Géų<íēŋÂ:d@œ,ŌC€:<ËôC€R<ČõRÅMV$CãB> ĒM?U}Á;ô";ōYĶGrâ‡G’ŽÁ?žáÁC‘ ģ!ē<ˆĻņģņ<ƒ[ŧŧRį\< ÅĩŊ-wĶ<#} ={Ō@?ŅJEEÜ6MB*Á,CYĢ”Cę…>C$ô<ˇŲ~Â9ī+@vĶ3C€€č<Ŧ >C€(<ĒušÅHŞCĨËí=ĶgB?ŊöÁën;Ø-Ž;ÖßyGrâ‡G’ŽÁ?žáÁC‘ ģo‘™<ũi<„^Â8ōĮ@6‚˙C€&<‡A`C€S<†C=Å=šųCPĒÃ=ŒŪG?”;xÁ‚Ė;­Ė;Ŧŋ‰G’ŽG’ŽÁC‘ÁC‘ 쀿€<ĄgēËj<Ōŧ ‘B;°uŧŦ{Ž;­đB =Âũ@?ũ=ĩEš@A’ÍBģ"ˇB„S*>5K<ˇIÂ7Β?Öŋ•C€<<'ĶC€Ņ<&ĒÅ2ČC\Ë=O|Ą?—Î…ÁæÚ;\Ėh;\^G’ŽG’ŽÁC‘ÁC‘ ģžÍą;˜b:;—5TģŊYĩ;‡>Uģ˛1; =L\@@ HWEĻ:āAĒBØČB™Iū>*ũ;áËG’ŽGģ+ŋÁC‘ÁGBĨ ŧW<;Ûã3ēęĀe;Ų_qēmåj;†đ<‡Ą^;ˆD @u`˜@@5úE˜úŸAūôBKˇĢB Ė>5 ;×[ņÂįXē@@(ˆ-EŒãÕA-5˙Bm-ĮB'ĩô>?ņ<Â1Ûę?ާ˜C€€.<@ĸwC€€üCŽ#@@9b˜EËė@ôR1B2úAû×#>Dœ;Ÿę(Â6X§?WXŽC€é<’ C€€f<:ČÄŽĸˇAūmŧ<ē|?ĻüÆÁŧ;ęŌ;¯įH Ē3H?QÁN5ÁNåv9Îŋ™;=xÂēŗ;5 ēŠ:K;˙ģÍÃ;3 >ŨH@@KėtEiĐā@}AÔ;ŦA–K>AM|;Y øÂ3 Ÿ?ĖâC€ũ;ÁŦ?C€ų;Áí‹Ä‰ A´8<¨K?Ģ›Áëv:¨[ :¨ ŊH Ē3H9´ÁN5ÁSI%¸Ŗâš:ųÛu¸¨ĸ:ų~Í:ŊŅ::į˜;T§:æė& =ŋŲį@@`PæESĖĻ@Ą'mAūΎA´,ø>AËą;‹Ą(Â8ļˆ?97rC€€š< =GC€ų< gmÄW˙A’ <­M?¯#KÁ 3¨:Ķ—):ĶcH,ëÃH9´ÁQíÁSI!%ēy]Í;#EÕšƒ‰;!C`ēK(t;āĐ;Žãf;, >Lėß@@vŋdEAC@'ĸjAŠūÁAD‘>F ;M Â4ŧK>ŋÃ)C€ē; ÅąC€ũ; žiÄ=ĐøAģJF÷o:Ö­āÂ18>ŠkZC€€;€!ßC€€;€žLÄ&ŧ@Ųf˛<'Í?ˇ¯§Á âŽ:<°v:<ËHEãŽHn$ÁTDÔÁW|J)9:P(:}}¸ 6ų:{ĩ<:cŌ(:j*ķēŊF´:h–H =U@@•HFE`@ qAld_A''‰>G˛Æ; ;ŽÂ3îl>°[@C€ü;ļũC€€;ļžÄ…ô@ĸßŨ<"6?ŧ‘Á Đ:gÅė:gf™HtDKH…”ÁWí4ÁYjŽ;E9ėŽ:Ŗ÷EšĀˇÁ:Ĩ3Ÿ7"B°:”Ēö;'‹:•І2?S€@@¤5įE–œ?Š™Ađé@ē—Ä>I­7:”v™Â36 >=>ĀC€ō;W*ũC€>;WfÚÃä-@HIp:ŒTFÂ2ëĨ>4\ôC€€S;_ĢzC€€I;_ūŌÃļ×Ü@ ž;Å Ũ?ÅLÁn'9ū]Ē9ūmęH“¸5H e˜Á[;Á\Š9Sa9´ô:)fš×*:'^žšFß:3:˛ÄŸ:‡u =8ä‡@@ÆąŪDæ6ú?$}ņ@ŦĄ@sIq>JdÂ:R”fÂ49Ą>ƒ C€€ĸ;8¯wC€ē;8 šÃ›Vk? ē;„q5?ĘÁ›ã9Æßu9ÆWęHĄįH´^Á\ēÁ^ŗ ayē2Ë;9÷ø˜8=>Ĩ9ö¯č¸4į`9æžē‚# 9ää3# <=ē@@ڐuDÍũÕ>ČsÛ@]K+@zm>I¸: ÜfÂ3žÉ=ļŽ?C€€+; yđC€Ŋ; …ŽÃ}æP?;R0;<Ūá?ÎíSÁ­u9†ū‚9‡uĖHŗĖHÆgãÁ^žÁ`Zúw‘ēLô9ĻĻ’š=ā9ŖÂˇš‹9ĄMc9öN9ž‹u' ;Ú71@@đk´D¸{i>^Ŗ_?˙f?´[‡>JëŠ9Š&JÂ4’ĩ=TK‚C€å:˛ųĸC€ų:˛Ķ7ÃRnÛ?ÖĖ;ŧÜ?ĶęĩĀũ†9'ž?9'ĖNHÂÖųHÛ´=Á` QÁb q‹ą9‘ā9KŨ`7M°9Lxũ¸Ū¨Ĩ9@x;šĘ9A * ‰€A@$۟?é$ī>KeW9æ26Â3“Ļ=‘˛C€€;Ē[C€×;ĀÃ.B>Ė; W?ŲåĀų™,9hŪ9gāHŨÎŪHõzĀÁbJÔÁd ÆŗŨš¤5B9‰GŠ7mZ9Š> 9Œw˙9ōaš˙˜d9‚čŸ. ;r{ö@At]D’ĀŠ>;|+?ė°1?§]&>K 9¯PMÂ3ā‘=\´C€é:ā¸gC€ĩ:ā‰à °Ī>ƒģ4:î~?ŪB Āõ’î91'91žØHđËKI(äÁc¸Áex%Ķšg˙9PÜ9='9OŪhĩpŧ9IÁÖ9Žž9I˛/3 ;*Ią@A D‚ŦX>d–?Á¨Ũ?ˆđ#>KFˇ9—˙Â4ķ==ĄC€đ:ÔÖÖC€€:Ô՞Âč€>:Z:ͅE?㞊ĀņŠÉ9ÎH9ЉIĨIFáÁeECÁg÷ũ=8q-98ĪôˇãšQ98ūĪ67ŖG947b9”ą94w8:‰JØ@A0Dhh„=Ų&?—!?Uē–>K°M9tÄ/Â4C&=kßC€€:ž`œC€€:žNžÂŊŠ =ûCã:Š“š?éČĀíw9°ķ9ú°IŦYIÍåÁfĘÁh‡e/y86-Î9s§¸įÍ'9•Ĩ¸„Š9ÕÛ8šŋŠ9L> ;;k@AA™šDNXy=¤ÂÍ?p-¤?)Ôę>KÅú9PˆÂ44=ĐdC€€ :ąģC€˙:ąĩ#™ČĶ=§õ„:‹ĖH?îēÉĀéU8Ũŧ8ŪwI÷¯I/9ÁhS¨Áj8°mҏ™@9]íˇ“§:8˙ũŨ¸Ÿ‰Á8ø#ŋ9{ߛ8÷ƒŽD :đß^@ATõÃD7{=lˇE?3ü_>ū‰Ô>KÖ|9.#WÂ4)r<؛C€€5:ĸæ…C€ë:ĸÜUÂyBz=Ph}:V 7?ô|}Āå-Õ8ŗV8´I,) I=PÁiî”Ák™hŊ)ši8ŌŊaˇŪf8Ôh7¸"9-8Éū9{Tņ8ËŦŸJ :psÔ@AjAŠD"bŦ=”/>×ng>˜U9>Lô8×FcÂ47<†ĖũC€÷:^F8C€ü:^CåÂIĶ<éÂ:°?úaēĀá18e„)8fë„I˛Ã˛>|Τ>LMX8Ŧ(ĮÂ4ˆ”é´>R˜;>LDĸ8ēÜŪÂ4ßYëã>ū>L¤8~ķXÂ4<(ęC€Ų:/*đC€ü:/(ãÁĪÄt<uE9ļė@wĢĀԃÅ8ņŅ8pIqÁtI…j¨ÁoÔXÁq‹Cĩą8—KĶ8mˇ:58M ¸V:8/o¸¸”Ŗ8rÂm :LI}@AĢ|ŗCÉ`F;é§ū=â<–=Ÿų6>L‰8Q”QÂ49<ípC€ü:vBC€€:rÁÁ§ ?#i°<ú`ŋ@ ĩĀĀĐg-7ĸ7 ęI„5{Iœ^ÁqbĶÁrņ’Š4čŦ°8îˇY˜8‘ļŠŖŋ7ûYG¸ąˇĖ7üMx 9Úc@AŧĸÅCŗ2w;æI?=éÛN=Ĩ\”>L…m8@Â4†;ģŧÅC€õ9ųę"C€ô9ųé”Á„Û%>Ô:™<ĖxÁ@ ŲĀĖYP7°ĩč7´ |IIœĩ%ÁrÂ~ÁtVÖ{Õ5Į´7ē‘=ļCc7ēŠíˇRãF7ēHÄ6Ŗ“ž7ēo[„ : -@AĪŲCŸâJ;ė*4=û[â=ąŧĶ>L 8÷Â3ūĀ;ŗ‘kC€Ü:ĸ’C€ß:ĸĪÁKes>”tF<ēŲ2@nrĀČc7Ë&O7Ī8I›ßIŠ*tÁt'ÁuĢ -ļ l¯7˛\~7ya7˛´ģ6ž„Ė7ŽKЏ_éŌ7Žų'‘ 9“ŨŲ@Aä?ÕCä>LŠ~7ÛqžÂ4´;ˆg5C€€9Üi¯C€ô9ÜiÁ#Ęæ>JķÉ<žš6@ę Āćs´,âu4,âuI¨I¸[ÎÁuŒrÁw)‚ũ ˇQZA7‡šļ$l'7ˆB>6iŗ¨7ƒ–ϏLî7„†&Ÿ 9M–@AûC€‡;f¤š=†×=>˛>LĢ7´ÛwÂ3ũŗ;`ö˜C€ķ9ĮßCC€ō9ĮßōÁ Ú> E<‰Ō@{ ĀĀÍY7|U7ub IļXÆIĮãdÁvøļÁx‘- ¯ ĩĪBd7`.Lļˆˆ7a(Ö6‰Ę7[Ąũ¸Xé7\㯠9HĶÖ@B BCgôĄ;LË´=zŌ=1[š>LĒ7‹ķ¯Â4;-žŪC€€9Šī1C€û9Šî$ĀÆcņ=Ŧč<^@";ĀŊ;~7xY'7rōĻIÅëŨIŲ"ôÁxe3ÁzB ģĄˇĒĨ7*čˇ2•7,Îļ™!Ô7*qO7ĀT7+ĩ5Ā 8…ą@BæbCRi°>L°Į7_îÂ3ūË; lČC€€9•Í×C€€ 9•ΚYŒ=€ËoLŧ7;ÆÂÂ3ũ—:閊C€ü9Š!ŪC€ũ9Š"\Āpã°=īō<(č<@"´ŖĀļŠÍ6ŧÉ[6ÁfËIéÎJqÁ{Lŧ­7)ŦÂ3ũ]:ļØ{C€ū9n C€˙9n Ā7ö<Ō×ŋ<ŗß@&Ą Āŗŗ6Ę&Š6ÉoƒIũ™âJ .Á|ŗŸÁ~n$ĢÕ6Œ¤S6ļQŋļJĄm6ˇCÅ6žP6ŗ•Oˇmģ6´ĄJ 8ZÊ@BJ-ČC#o:Lã~<˜¯Lžč6ŪÃEÂ4¸:Š•‡C€ö9FNŽC€ķ9FNxĀBD<Ŧ9<Cg@*Ĩ¨Ā°ú6Žđu6ŦæūJ aŠJz˜Á~8IÁ€f ]5Ÿė‡6Špõ5­SĘ6ŠöEĩįķ*6‰ ˇir6‰ˇ 8kT@B^eCĘ$:{ŸÚ<ÃË.<Šrq>LŋŨ6ļ҆Â3˙:cPÚC€€92ø“C€ų92øąŋŲ&ŋ>LÂã6˜ōüÂ4°:>ģzC€ü9$ėC€û9$ëķŋ¤×<\č;ČII@2ųįĀŦEå3“nˆŗ“nˆJ&.J9€GÁ€ŗ2Á§Ä$Ë-ņļŋR6=m´[Į56=ā„ĩŠÂŗ6=UÃļ^6=¯§V 7ģš@B†ŒĪC~>LÃá6pEæÂ3˙Ņ:ģUC€ū9v%C€€9v/ŋw’”;¤Žß;ĒIø@7JžĀĒKž2áΞáĪJ6Š„JM8{Áƒ˙Á‚ˆg,_7Š6˛ã6 ļ}Ņ6YĩWŧ6ül4Ũĸ;6VQx 7r×@B”JB˙âh:„oÍ<íä*<¨6ë>LÄđ6RÂ3˙ō:ģĪC€€9đTC€€9đWŋ;é;Yė4;•@;ļ9Ĩ7ģ}7 JIŦ}Jc†ÜÁ‚aŖÁƒmÖ5ˇC)3•dX6Íß4§)C6A?4dĻP6Ɖ4YY˜6FĖ 7Ū}@BĸÎ8Bõ ô9Ī‘LÆY6õAÂ4e9Æ ĻC€˙8äŪC€€8äŧŋ |;Ă;‚ũĀ@@<ũ§•6zŗ46])ŠJ_ĪįJ~ĩÁƒI<Á„cA Qiŗw15Æ/ŋĩ‰Ä…5Əö4„65ÂĮ ļÁ´D5Ã>ŨĮ 7@y4@Bŗ Bė˜>LĮ76 ĪÔÂ3˙Ņ9̘gC€˙8ŲmC€ü8ŲmŸžŅƒÃ:ˇøí;`Ęb@D߲ĀĨĀ3ÔxwŗÔxwJy‰-JŽetÁ„;'Á…aNŋbiĩRr5̃ŠĩÉj 5ŦōĩŽéč5Ģ 85­Ôë5̝ßô 7eMŋ@BÄūĻBäÍÚ>LČ5Üw+Â4"9‰@ûC€€8ŋNûC€˙8ŋNņžš…œ:hÃe;@Īį@IŸ¤Ŧē4yLÍ´yLÍJ‹ĪÃJ Ŧ_Á…8IÁ†m‰_Uw=˛.Ž5‰bëĩæ5‰ČĢ´ū'5ˆ‡h60Ô÷5ˆúč& 6Ø| @BØąˇBŪŲ>LČŋ5Ŋ9ßÂ49k›ŋC€€8´žûC€˙8´žøžc=h:XL;$Ũã@N{ĻĀŖÂ4HR´HRJcĄJļ !Á†?Á‡ƒ'sG)5Zl5kz>ĩ’īē5l(‰´…ƒ5jîÆ5šžĀ5k´] 6Ļ$Ā@Bî]BÚŋ>LÉĘ5šQ6Â49?ūbC€˙8ĄųŋC€€8ĄųŊž&Ř9Ŋũ¸;Ō5@SvJĀĸ˙iŗ¯u3¯uJ˛)&JĪdEÁ‡SCÁˆĨ‹kŽM4¯d;5@!Ũĩ=âÛ5@Õ ŗųëč5@ Ļ4ƀœ5@͍™ 6N.@C™BÖ":.ēú<Đė <“ģW>LĘa5„Â49$_ØC€ū8˜|C€ũ8˜|Ŋķ‡9zä;Ūy@XĒĀĸ_d6éÅQ6ŲīmJĘáwJíÅzÁˆt-Á‰Õ ¨šŌũ3Ah5$YŌĩ‰dÚ5$ÖR09 5#į_ĩž?^5$s§Ü 60Ø@C5ÂBĶt>LĘŖ5^.­Â49 EüC€ū8‹C€˙8‰Ŋ¯øy9"ŗ:ŨS6@]Č…ĀĄŨ4ĐĶ´ĐĶJčQKö€Á‰ĄtÁ‹ÉĖ1˙5ŗŧuU5 tdĩK5 åé4ŒQ5 å{ĩ¨Šđ5 dI% 6>ŲP@CĄ‰BЊÆ9Ņ}O<‰ļ LË5B™æÂ48ō¯C€€8‡íNC€˙8‡íMŊ}Tæ8Ÿŧū:Ąkš@c!ĀĄsū6‡îI6KKˇ=K¨NÁŠÚoÁŒVŧ÷4Ņ´q}°4ō>4ĩ0BÜ4ķ'ų´â™4ņФĩmđ4ōĒžu 5͚[@C.~}BΔz9qįŌ<&Ķ;ëėũ>LË5#ûÂ3˙û8˞C€˙8| 7C€ū8| :Ŋ5ƒ8–n:Ô@ą@h›¸ĀĄę6*ŸÂ6JNKąēK8´ˆÁŒ|Á¨Ā*Ķuĩ1‰nĘ4ĖNŨĩx 4Ėų2”ô^4Ėí5āĻ4ĖÔ×Î 5ĢŠ§@C?ņŠBÍ m9-CI;ú—@;ą1É>LË}5ÖÂ48˛ķzC€ũ8s%ņC€ū8s%ņŊ§8^#:Ũ`!@n7ĸĀ Ũ95÷5åœuK3öKWøļÁnåÁ}iŗÄ43rŅ4ŗOų´5z4ŗönŗ—ˆ24˛ã}ĩ@ī4ŗ›L/ 4ÕP~@CS#KBËÔŽ>L˒5ČWÂ48 5tC€˙8ovUC€€8ovTŧĩÂ[@sö-Ā ¨Āŗ„33„3KROLK}ŦXÁŽÉkÁjCĩš#4cžķ4 u‚´´ĘĐ4Ą éŗZå”4 løŗĒ4Ą˙š 5eˆ@Ch@lBĘäõ>LËÆ4ä*šÂ3˙ũ8ņ×C€˙8i\C€ū8i\ŧ~Ķr7¯˙×:°Īl@yØ-Ā Ė4û´ûKvĘŲK•”hÁ-Á‘Ų—–)´“E94Žā´ÁĶM4ŽĄ/´ŗ,4Ž ­´h0A4Ž›Ä 4&ll@CzBĘ,:]ā<ãë< Ũ×>LĖ4ОoÂ3˙ũ8ēNC€˙8j¨ÉC€ū8j¨Ęŧ1bN7ĒÎĩ:ö‚=@Ū~Ā `6ÁĢl6´GnK‘dmK°ķPÁ‘™ŨÁ“N§€Ņ! ´š14ëj´›n04‚oą˛dú~4×X´ 14‚hG‘ 4i(˙@CŒƒ#Bɞm>LĖ4ŧĘâÂ3˙ú8qkC€˙8lŲ@C€ū8lŲBģõŒH7vũ;ĀD@ƒĀ G´˛ÖZ’2ÖZ’KĢéLĖ5üÂ3˙ú9ÄVC€˙8ņk„C€ū8ņk‹ģ¨=@†-ĖĀ 5ŗ† 3† KÁēKĶ›(Á”ßÁ”Ü[rŸŅcŗĖ#4ôĩ´á:Ē4ĩgO´lc&4Ī,ŗĩ˜c4Ę\,‘5„›@XTENSION= 'BINTABLE' / binary table extension BITPIX = 8 / 8-bit bytes NAXIS = 2 / 2-dimensional binary table NAXIS1 = 160 / width of table in bytes NAXIS2 = 69 PCOUNT = 0 / size of special data area GCOUNT = 1 / one data group (required keyword) TFIELDS = 40 TTYPE1 = 'SMA ' / label for field 1 TFORM1 = '1E ' / data format of field: 4-byte REAL TUNIT1 = 'pixel ' / physical unit of field TTYPE2 = 'INTENS ' / label for field 2 TFORM2 = '1E ' / data format of field: 4-byte REAL TTYPE3 = 'INT_ERR ' / label for field 3 TFORM3 = '1E ' / data format of field: 4-byte REAL TTYPE4 = 'PIX_VAR ' / label for field 4 TFORM4 = '1E ' / data format of field: 4-byte REAL TTYPE5 = 'RMS ' / label for field 5 TFORM5 = '1E ' / data format of field: 4-byte REAL TTYPE6 = 'ELLIP ' / label for field 6 TFORM6 = '1E ' / data format of field: 4-byte REAL TTYPE7 = 'ELLIP_ERR' / label for field 7 TFORM7 = '1E ' / data format of field: 4-byte REAL TTYPE8 = 'PA ' / label for field 8 TFORM8 = '1E ' / data format of field: 4-byte REAL TUNIT8 = 'deg ' / physical unit of field TTYPE9 = 'PA_ERR ' / label for field 9 TFORM9 = '1E ' / data format of field: 4-byte REAL TUNIT9 = 'deg ' / physical unit of field TTYPE10 = 'X0 ' / label for field 10 TFORM10 = '1E ' / data format of field: 4-byte REAL TUNIT10 = 'pixel ' / physical unit of field TTYPE11 = 'X0_ERR ' / label for field 11 TFORM11 = '1E ' / data format of field: 4-byte REAL TUNIT11 = 'pixel ' / physical unit of field TTYPE12 = 'Y0 ' / label for field 12 TFORM12 = '1E ' / data format of field: 4-byte REAL TUNIT12 = 'pixel ' / physical unit of field TTYPE13 = 'Y0_ERR ' / label for field 13 TFORM13 = '1E ' / data format of field: 4-byte REAL TUNIT13 = 'pixel ' / physical unit of field TTYPE14 = 'GRAD ' / label for field 14 TFORM14 = '1E ' / data format of field: 4-byte REAL TTYPE15 = 'GRAD_ERR' / label for field 15 TFORM15 = '1E ' / data format of field: 4-byte REAL TTYPE16 = 'GRAD_R_ERR' / label for field 16 TFORM16 = '1E ' / data format of field: 4-byte REAL TTYPE17 = 'RSMA ' / label for field 17 TFORM17 = '1E ' / data format of field: 4-byte REAL TUNIT17 = 'pix(1/4)' / physical unit of field TTYPE18 = 'MAG ' / label for field 18 TFORM18 = '1E ' / data format of field: 4-byte REAL TTYPE19 = 'MAG_LERR' / label for field 19 TFORM19 = '1E ' / data format of field: 4-byte REAL TTYPE20 = 'MAG_UERR' / label for field 20 TFORM20 = '1E ' / data format of field: 4-byte REAL TTYPE21 = 'TFLUX_E ' / label for field 21 TFORM21 = '1E ' / data format of field: 4-byte REAL TTYPE22 = 'TFLUX_C ' / label for field 22 TFORM22 = '1E ' / data format of field: 4-byte REAL TTYPE23 = 'TMAG_E ' / label for field 23 TFORM23 = '1E ' / data format of field: 4-byte REAL TTYPE24 = 'TMAG_C ' / label for field 24 TFORM24 = '1E ' / data format of field: 4-byte REAL TTYPE25 = 'NPIX_E ' / label for field 25 TFORM25 = '1J ' / data format of field: 4-byte INTEGER TTYPE26 = 'NPIX_C ' / label for field 26 TFORM26 = '1J ' / data format of field: 4-byte INTEGER TTYPE27 = 'A3 ' / label for field 27 TFORM27 = '1E ' / data format of field: 4-byte REAL TTYPE28 = 'A3_ERR ' / label for field 28 TFORM28 = '1E ' / data format of field: 4-byte REAL TTYPE29 = 'B3 ' / label for field 29 TFORM29 = '1E ' / data format of field: 4-byte REAL TTYPE30 = 'B3_ERR ' / label for field 30 TFORM30 = '1E ' / data format of field: 4-byte REAL TTYPE31 = 'A4 ' / label for field 31 TFORM31 = '1E ' / data format of field: 4-byte REAL TTYPE32 = 'A4_ERR ' / label for field 32 TFORM32 = '1E ' / data format of field: 4-byte REAL TTYPE33 = 'B4 ' / label for field 33 TFORM33 = '1E ' / data format of field: 4-byte REAL TTYPE34 = 'B4_ERR ' / label for field 34 TFORM34 = '1E ' / data format of field: 4-byte REAL TTYPE35 = 'NDATA ' / label for field 35 TFORM35 = '1J ' / data format of field: 4-byte INTEGER TTYPE36 = 'NFLAG ' / label for field 36 TFORM36 = '1J ' / data format of field: 4-byte INTEGER TTYPE37 = 'NITER ' / label for field 37 TFORM37 = '1J ' / data format of field: 4-byte INTEGER TTYPE38 = 'STOP ' / label for field 38 TFORM38 = '1J ' / data format of field: 4-byte INTEGER TTYPE39 = 'A_BIG ' / label for field 39 TFORM39 = '1E ' / data format of field: 4-byte REAL TTYPE40 = 'SAREA ' / label for field 40 TFORM40 = '1E ' / data format of field: 4-byte REAL TUNIT40 = 'pixel ' / physical unit of field TDISP1 = 'F7.2 ' / display format TDISP2 = 'G10.3 ' / display format TDISP3 = 'G10.3 ' / display format TDISP4 = 'G9.3 ' / display format TDISP5 = 'G9.3 ' / display format TDISP6 = 'F6.4 ' / display format TDISP7 = 'F6.4 ' / display format TDISP8 = 'F6.2 ' / display format TDISP9 = 'F6.2 ' / display format TDISP10 = 'F7.2 ' / display format TDISP11 = 'F6.2 ' / display format TDISP12 = 'F7.2 ' / display format TDISP13 = 'F6.2 ' / display format TDISP14 = 'G8.3 ' / display format TDISP15 = 'G6.3 ' / display format TDISP16 = 'G6.3 ' / display format TDISP17 = 'F7.5 ' / display format TDISP18 = 'G7.3 ' / display format TDISP19 = 'G7.3 ' / display format TDISP20 = 'G7.3 ' / display format TDISP21 = 'G12.5 ' / display format TDISP22 = 'G12.5 ' / display format TDISP23 = 'G7.3 ' / display format TDISP24 = 'G7.3 ' / display format TDISP25 = 'I6 ' / display format TNULL25 = -2147483647 / undefined value for column TDISP26 = 'I6 ' / display format TNULL26 = -2147483647 / undefined value for column TDISP27 = 'G9.3 ' / display format TDISP28 = 'G7.3 ' / display format TDISP29 = 'G9.3 ' / display format TDISP30 = 'G7.3 ' / display format TDISP31 = 'G9.3 ' / display format TDISP32 = 'G7.3 ' / display format TDISP33 = 'G9.3 ' / display format TDISP34 = 'G7.3 ' / display format TDISP35 = 'I5 ' / display format TNULL35 = -2147483647 / undefined value for column TDISP36 = 'I5 ' / display format TNULL36 = -2147483647 / undefined value for column TDISP37 = 'I3 ' / display format TNULL37 = -2147483647 / undefined value for column TDISP38 = 'I2 ' / display format TNULL38 = -2147483647 / undefined value for column TDISP39 = 'G9.3 ' / display format TDISP40 = 'F5.1 ' / display format IMAGE = 'synth.fits' END F’*˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙C€€˙˙˙˙C€~z˙˙˙˙Ãvô˙˙˙˙˙˙˙˙ÁÅ+˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙?__F‘^A’˜ÃBēßųB„#ø>“ü7>)ěÂ=ũœAžė›C€€=]?€C€~z=W‹(ĨPD R$?‰0?Y~`Á‡Œ;‹; ÉF’*F’*ÁÅ+ÁÅ+ŧ× h=äØģ‹zĢ=ÔVŊ•O.=§—Ôž °>­#^ =s z@?ĩĩF%˛Aąq™Bâ2BŸō>“ü7>)Ā Â=ū AžčUC€€=sYC€~T=mÄ ŠÍD$P?‰•å?^ŧüÁz€;,wŽ;,üF’*F’*ÁÅ+ÁÅ+ŧ× a=ä+ģË =ÔSPŊ•UĒ=§•Ģž Ģ5>­ =Šƒf@?!azFŖgAÖ´qCŲBÁˆG>“ü7>)ĘBÂ>ĨAžņæC€€=…āC€~*=‚jÄ­D'Zų?‰š[?dųÁjŠ;Q|;PäcF’*F’*ÁÅ+ÁÅ+ŧÖSû=ä5ģŽQž=Ô`,Ŋ•­˙=§÷ž ˛!>­" =ʎ@?1„ĶFŋBŲC%†&Bę>“ü7>)ŋÂ>üAžįkC€€=“9ųC€}ú=kŌÄ+D\D87?‰G?iœÁWk;~‘s;}­PF’*F’*ÁÅ+ÁÅ+ŧ֞j=ä‘ģŽ˛:=ÔQëŊ•›ī=§ÖĀž ¨Z>­ Ū =ī+ŋ@?CEOF G B)#CHWcC ŠŠ>“ü7>)ÉåÂ>īAžņC€ū=ĄũĩC€}É=ÍÄ6Á@;šî2;šDoF’*F’*ÁÅ+ÁÅ+ŧÕuS=ä ģ_=Ô`TŊ•Āã=¨ æž ą/>­"4 > U@?VĖ>F `B>?CrY\C+]Ū>“ü7>)ˇœÂ=ųŪAžāpC€€=˛ÁC€}‹=­„āÄO@ĮD^ˇz?‰Œį?uÁ#‹;ŧŗš;ģģŦF’*F’*ÁÅ+ÁÅ+ŧ×^Î=äÖģŒë¨=ÔF(Ŋ•)=§^üž Ŗž>­Š >6™‘@?lGF HĐBfbC’ĸ—CO_•>“ü7>)ÃēÂ=˙EAžëČC€€=ÃųC€}N=žë5ÄcīDuQ?‰–6?zë’ÁØ;æG‰;äŅÆF’*F’*ÁÅ+ÁÅ+ŧ× )=äËģ•|=ÔV`Ŋ•l =§­Đž ­Í>­< >;t@?ķãF öũB‹6ˆCąvdCzøF>“ü7>)Į{Â>ÔAžīLC€ū=טĪC€} =ŌĐÄzŋûD†Æá?‰™H?€|CÁփ< Ë < ŦôF’*G5ö´ÁÅ+Á:ēŸŧÕø=äķģ´=Ô\TŊ•ĸŊ=§ęģž ¯ô>­u >jöq@?Žō­FXãB¨ČĒC×(tC˜#Ã>“IÕ=ë7ÁÂ=dĮA]3C€ö=ŖīÄC€|ŧ=ŸûLÄĮŨzDŽY’?6T¯?ƒ•kÁĸ<,á-<+3”F’*G5ö´ÁÅ+Á:ēŸŧ—rb=ší×ģ]ĸk=’ŧÎŊEú)=N$ž^c >4€ ?Öũ@?>%FąBĻÎ{CÔŖ2C–[>wcA=… Â7'eAūC€€Ô=BoC€~ļ=A'KÅ(éŪD`|r>Ē­?†Á˛Áĩ<0t}<.ŧŦG5ö´G5ö´Á:ēŸÁ:ēŸŧ,‹Ô="šĀģ´ĩI=āßŧˇo5SDŠ=(Î*Â;Ô @Ķ+IC€€˙=ZC€Z=–ÅHŖ}Dčß>JÁÆ?АÁ,]<ū~<ĖÖG5ö´G5ö´Á:ēŸÁ:ēŸģ‡ T<ĨWģ‹N<Ŋ|ŧ€:6<+¸<Ŋ….Œ<ƒTĪ > Ę1@?žC„Eëü;BNxdCƒ™xC:>Géų<íēŋÂ:d@œ,ŌC€:<ËôC€R<ČõRÅMV$CãB> ĒM?U}Á;ô";ōYĶGrâ‡G’ŽÁ?žáÁC‘ ģ!ē<ˆĻņģņ<ƒ[ŧŧRį\< ÅĩŊ-wĶ<#} ={Ō@?ŅJEEÜ6MB*Á,CYĢ”Cę…>C$ô<ˇŲ~Â9ī+@vĶ3C€€č<Ŧ >C€(<ĒušÅHŞCĨËí=ĶgB?ŊöÁën;Ø-Ž;ÖßyGrâ‡G’ŽÁ?žáÁC‘ ģo‘™<ũi<„^Â8ōĮ@6‚˙C€&<‡A`C€S<†C=Å=šųCPĒÃ=ŒŪG?”;xÁ‚Ė;­Ė;Ŧŋ‰G’ŽG’ŽÁC‘ÁC‘ 쀿€<ĄgēËj<Ōŧ ‘B;°uŧŦ{Ž;­đB =Âũ@?ũ=ĩEš@A’ÍBģ"ˇB„S*>5K<ˇIÂ7Β?Öŋ•C€<<'ĶC€Ņ<&ĒÅ2ČC\Ë=O|Ą?—Î…ÁæÚ;\Ėh;\^G’ŽG’ŽÁC‘ÁC‘ ģžÍą;˜b:;—5TģŊYĩ;‡>Uģ˛1; =L\@@ HWEĻ:āAĒBØČB™Iū>*ũ;áËG’ŽGģ+ŋÁC‘ÁGBĨ ŧW<;Ûã3ēęĀe;Ų_qēmåj;†đ<‡Ą^;ˆD @u`˜@@5úE˜úŸAūôBKˇĢB Ė>5 ;×[ņÂįXē@@(ˆ-EŒãÕA-5˙Bm-ĮB'ĩô>?ņ<Â1Ûę?ާ˜C€€.<@ĸwC€€üCŽ#@@9b˜EËė@ôR1B2úAû×#>Dœ;Ÿę(Â6X§?WXŽC€é<’ C€€f<:ČÄŽĸˇAūmŧ<ē|?ĻüÆÁŧ;ęŌ;¯įH Ē3H?QÁN5ÁNåv9Îŋ™;=xÂēŗ;5 ēŠ:K;˙ģÍÃ;3 >ŨH@@KėtEiĐā@}AÔ;ŦA–K>AM|;Y øÂ3 Ÿ?ĖâC€ũ;ÁŦ?C€ų;Áí‹Ä‰ A´8<¨K?Ģ›Áëv:¨[ :¨ ŊH Ē3H9´ÁN5ÁSI%¸Ŗâš:ųÛu¸¨ĸ:ų~Í:ŊŅ::į˜;T§:æė& =ŋŲį@@`PæESĖĻ@Ą'mAūΎA´,ø>AËą;‹Ą(Â8ļˆ?97rC€€š< =GC€ų< gmÄW˙A’ <­M?¯#KÁ 3¨:Ķ—):ĶcH,ëÃH9´ÁQíÁSI!%ēy]Í;#EÕšƒ‰;!C`ēK(t;āĐ;Žãf;, >Lėß@@vŋdEAC@'ĸjAŠūÁAD‘>F ;M Â4ŧK>ŋÃ)C€ē; ÅąC€ũ; žiÄ=ĐøAģJF÷o:Ö­āÂ18>ŠkZC€€;€!ßC€€;€žLÄ&ŧ@Ųf˛<'Í?ˇ¯§Á âŽ:<°v:<ËHEãŽHn$ÁTDÔÁW|J)9:P(:}}¸ 6ų:{ĩ<:cŌ(:j*ķēŊF´:h–H =U@@•HFE`@ qAld_A''‰>G˛Æ; ;ŽÂ3îl>°[@C€ü;ļũC€€;ļžÄ…ô@ĸßŨ<"6?ŧ‘Á Đ:gÅė:gf™HtDKH…”ÁWí4ÁYjŽ;E9ėŽ:Ŗ÷EšĀˇÁ:Ĩ3Ÿ7"B°:”Ēö;'‹:•І2?S€@@¤5įE–œ?Š™Ađé@ē—Ä>I­7:”v™Â36 >=>ĀC€ō;W*ũC€>;WfÚÃä-@HIp:ŒTFÂ2ëĨ>4\ôC€€S;_ĢzC€€I;_ūŌÃļ×Ü@ ž;Å Ũ?ÅLÁn'9ū]Ē9ūmęH“¸5H e˜Á[;Á\Š9Sa9´ô:)fš×*:'^žšFß:3:˛ÄŸ:‡u =8ä‡@@ÆąŪDæ6ú?$}ņ@ŦĄ@sIq>JdÂ:R”fÂ49Ą>ƒ C€€ĸ;8¯wC€ē;8 šÃ›Vk? ē;„q5?ĘÁ›ã9Æßu9ÆWęHĄįH´^Á\ēÁ^ŗ ayē2Ë;9÷ø˜8=>Ĩ9ö¯č¸4į`9æžē‚# 9ää3# <=ē@@ڐuDÍũÕ>ČsÛ@]K+@zm>I¸: ÜfÂ3žÉ=ļŽ?C€€+; yđC€Ŋ; …ŽÃ}æP?;R0;<Ūá?ÎíSÁ­u9†ū‚9‡uĖHŗĖHÆgãÁ^žÁ`Zúw‘ēLô9ĻĻ’š=ā9ŖÂˇš‹9ĄMc9öN9ž‹u' ;Ú71@@đk´D¸{i>^Ŗ_?˙f?´[‡>JëŠ9Š&JÂ4’ĩ=TK‚C€å:˛ųĸC€ų:˛Ķ7ÃRnÛ?ÖĖ;ŧÜ?ĶęĩĀũ†9'ž?9'ĖNHÂÖųHÛ´=Á` QÁb q‹ą9‘ā9KŨ`7M°9Lxũ¸Ū¨Ĩ9@x;šĘ9A * ‰€A@$۟?é$ī>KeW9æ26Â3“Ļ=‘˛C€€;Ē[C€×;ĀÃ.B>Ė; W?ŲåĀų™,9hŪ9gāHŨÎŪHõzĀÁbJÔÁd ÆŗŨš¤5B9‰GŠ7mZ9Š> 9Œw˙9ōaš˙˜d9‚čŸ. ;r{ö@At]D’ĀŠ>;|+?ė°1?§]&>K 9¯PMÂ3ā‘=\´C€é:ā¸gC€ĩ:ā‰à °Ī>ƒģ4:î~?ŪB Āõ’î91'91žØHđËKI(äÁc¸Áex%Ķšg˙9PÜ9='9OŪhĩpŧ9IÁÖ9Žž9I˛/3 ;*Ią@A D‚ŦX>d–?Á¨Ũ?ˆđ#>KFˇ9—˙Â4ķ==ĄC€đ:ÔÖÖC€€:Ô՞Âč€>:Z:ͅE?㞊ĀņŠÉ9ÎH9ЉIĨIFáÁeECÁg÷ũ=8q-98ĪôˇãšQ98ūĪ67ŖG947b9”ą94w8:‰JØ@A0Dhh„=Ų&?—!?Uē–>K°M9tÄ/Â4C&=kßC€€:ž`œC€€:žNžÂŊŠ =ûCã:Š“š?éČĀíw9°ķ9ú°IŦYIÍåÁfĘÁh‡e/y86-Î9s§¸įÍ'9•Ĩ¸„Š9ÕÛ8šŋŠ9L> ;;k@AA™šDNXy=¤ÂÍ?p-¤?)Ôę>KÅú9PˆÂ44=ĐdC€€ :ąģC€˙:ąĩ#™ČĶ=§õ„:‹ĖH?îēÉĀéU8Ũŧ8ŪwI÷¯I/9ÁhS¨Áj8°mҏ™@9]íˇ“§:8˙ũŨ¸Ÿ‰Á8ø#ŋ9{ߛ8÷ƒŽD :đß^@ATõÃD7{=lˇE?3ü_>ū‰Ô>KÖ|9.#WÂ4)r<؛C€€5:ĸæ…C€ë:ĸÜUÂyBz=Ph}:V 7?ô|}Āå-Õ8ŗV8´I,) I=PÁiî”Ák™hŊ)ši8ŌŊaˇŪf8Ôh7¸"9-8Éū9{Tņ8ËŦŸJ :psÔ@AjAŠD"bŦ=”/>×ng>˜U9>Lô8×FcÂ47<†ĖũC€÷:^F8C€ü:^CåÂIĶ<éÂ:°?úaēĀá18e„)8fë„I˛Ã˛>|Τ>LMX8Ŧ(ĮÂ4ˆ”é´>R˜;>LDĸ8ēÜŪÂ4ßYëã>ū>L¤8~ķXÂ4<(ęC€Ų:/*đC€ü:/(ãÁĪÄt<uE9ļė@wĢĀԃÅ8ņŅ8pIqÁtI…j¨ÁoÔXÁq‹Cĩą8—KĶ8mˇ:58M ¸V:8/o¸¸”Ŗ8rÂm :LI}@AĢ|ŗCÉ`F;é§ū=â<–=Ÿų6>L‰8Q”QÂ49<ípC€ü:vBC€€:rÁÁ§ ?#i°<ú`ŋ@ ĩĀĀĐg-7ĸ7 ęI„5{Iœ^ÁqbĶÁrņ’Š4čŦ°8îˇY˜8‘ļŠŖŋ7ûYG¸ąˇĖ7üMx 9Úc@AŧĸÅCŗ2w;æI?=éÛN=Ĩ\”>L…m8@Â4†;ģŧÅC€õ9ųę"C€ô9ųé”Á„Û%>Ô:™<ĖxÁ@ ŲĀĖYP7°ĩč7´ |IIœĩ%ÁrÂ~ÁtVÖ{Õ5Į´7ē‘=ļCc7ēŠíˇRãF7ēHÄ6Ŗ“ž7ēo[„ : -@AĪŲCŸâJ;ė*4=û[â=ąŧĶ>L 8÷Â3ūĀ;ŗ‘kC€Ü:ĸ’C€ß:ĸĪÁKes>”tF<ēŲ2@nrĀČc7Ë&O7Ī8I›ßIŠ*tÁt'ÁuĢ -ļ l¯7˛\~7ya7˛´ģ6ž„Ė7ŽKЏ_éŌ7Žų'‘ 9“ŨŲ@Aä?ÕCä>LŠ~7ÛqžÂ4´;ˆg5C€€9Üi¯C€ô9ÜiÁ#Ęæ>JķÉ<žš6@ę Āćs´,âu4,âuI¨I¸[ÎÁuŒrÁw)‚ũ ˇQZA7‡šļ$l'7ˆB>6iŗ¨7ƒ–ϏLî7„†&Ÿ 9M–@AûC€‡;f¤š=†×=>˛>LĢ7´ÛwÂ3ũŗ;`ö˜C€ķ9ĮßCC€ō9ĮßōÁ Ú> E<‰Ō@{ ĀĀÍY7|U7ub IļXÆIĮãdÁvøļÁx‘- ¯ ĩĪBd7`.Lļˆˆ7a(Ö6‰Ę7[Ąũ¸Xé7\㯠9HĶÖ@B BCgôĄ;LË´=zŌ=1[š>LĒ7‹ķ¯Â4;-žŪC€€9Šī1C€û9Šî$ĀÆcņ=Ŧč<^@";ĀŊ;~7xY'7rōĻIÅëŨIŲ"ôÁxe3ÁzB ģĄˇĒĨ7*čˇ2•7,Îļ™!Ô7*qO7ĀT7+ĩ5Ā 8…ą@BæbCRi°>L°Į7_îÂ3ūË; lČC€€9•Í×C€€ 9•ΚYŒ=€ËoLŧ7;ÆÂÂ3ũ—:閊C€ü9Š!ŪC€ũ9Š"\Āpã°=īō<(č<@"´ŖĀļŠÍ6ŧÉ[6ÁfËIéÎJqÁ{Lŧ­7)ŦÂ3ũ]:ļØ{C€ū9n C€˙9n Ā7ö<Ō×ŋ<ŗß@&Ą Āŗŗ6Ę&Š6ÉoƒIũ™âJ .Á|ŗŸÁ~n$ĢÕ6Œ¤S6ļQŋļJĄm6ˇCÅ6žP6ŗ•Oˇmģ6´ĄJ 8ZÊ@BJ-ČC#o:Lã~<˜¯Lžč6ŪÃEÂ4¸:Š•‡C€ö9FNŽC€ķ9FNxĀBD<Ŧ9<Cg@*Ĩ¨Ā°ú6Žđu6ŦæūJ aŠJz˜Á~8IÁ€f ]5Ÿė‡6Špõ5­SĘ6ŠöEĩįķ*6‰ ˇir6‰ˇ 8kT@B^eCĘ$:{ŸÚ<ÃË.<Šrq>LŋŨ6ļ҆Â3˙:cPÚC€€92ø“C€ų92øąŋŲ&ŋ>LÂã6˜ōüÂ4°:>ģzC€ü9$ėC€û9$ëķŋ¤×<\č;ČII@2ųįĀŦEå3“nˆŗ“nˆJ&.J9€GÁ€ŗ2Á§Ä$Ë-ņļŋR6=m´[Į56=ā„ĩŠÂŗ6=UÃļ^6=¯§V 7ģš@B†ŒĪC~>LÃá6pEæÂ3˙Ņ:ģUC€ū9v%C€€9v/ŋw’”;¤Žß;ĒIø@7JžĀĒKž2áΞáĪJ6Š„JM8{Áƒ˙Á‚ˆg,_7Š6˛ã6 ļ}Ņ6YĩWŧ6ül4Ũĸ;6VQx 7r×@B”JB˙âh:„oÍ<íä*<¨6ë>LÄđ6RÂ3˙ō:ģĪC€€9đTC€€9đWŋ;é;Yė4;•@;ļ9Ĩ7ģ}7 JIŦ}Jc†ÜÁ‚aŖÁƒmÖ5ˇC)3•dX6Íß4§)C6A?4dĻP6Ɖ4YY˜6FĖ 7Ū}@BĸÎ8Bõ ô9Ī‘LÆY6õAÂ4e9Æ ĻC€˙8äŪC€€8äŧŋ |;Ă;‚ũĀ@@<ũ§•6zŗ46])ŠJ_ĪįJ~ĩÁƒI<Á„cA Qiŗw15Æ/ŋĩ‰Ä…5Əö4„65ÂĮ ļÁ´D5Ã>ŨĮ 7@y4@Bŗ Bė˜>LĮ76 ĪÔÂ3˙Ņ9̘gC€˙8ŲmC€ü8ŲmŸžŅƒÃ:ˇøí;`Ęb@D߲ĀĨĀ3ÔxwŗÔxwJy‰-JŽetÁ„;'Á…aNŋbiĩRr5̃ŠĩÉj 5ŦōĩŽéč5Ģ 85­Ôë5̝ßô 7eMŋ@BÄūĻBäÍÚ>LČ5Üw+Â4"9‰@ûC€€8ŋNûC€˙8ŋNņžš…œ:hÃe;@Īį@IŸ¤Ŧē4yLÍ´yLÍJ‹ĪÃJ Ŧ_Á…8IÁ†m‰_Uw=˛.Ž5‰bëĩæ5‰ČĢ´ū'5ˆ‡h60Ô÷5ˆúč& 6Ø| @BØąˇBŪŲ>LČŋ5Ŋ9ßÂ49k›ŋC€€8´žûC€˙8´žøžc=h:XL;$Ũã@N{ĻĀŖÂ4HR´HRJcĄJļ !Á†?Á‡ƒ'sG)5Zl5kz>ĩ’īē5l(‰´…ƒ5jîÆ5šžĀ5k´] 6Ļ$Ā@Bî]BÚŋ>LÉĘ5šQ6Â49?ūbC€˙8ĄųŋC€€8ĄųŊž&Ř9Ŋũ¸;Ō5@SvJĀĸ˙iŗ¯u3¯uJ˛)&JĪdEÁ‡SCÁˆĨ‹kŽM4¯d;5@!Ũĩ=âÛ5@Õ ŗųëč5@ Ļ4ƀœ5@͍™ 6N.@C™BÖ":.ēú<Đė <“ģW>LĘa5„Â49$_ØC€ū8˜|C€ũ8˜|Ŋķ‡9zä;Ūy@XĒĀĸ_d6éÅQ6ŲīmJĘáwJíÅzÁˆt-Á‰Õ ¨šŌũ3Ah5$YŌĩ‰dÚ5$ÖR09 5#į_ĩž?^5$s§Ü 60Ø@C5ÂBĶt>LĘŖ5^.­Â49 EüC€ū8‹C€˙8‰Ŋ¯øy9"ŗ:ŨS6@]Č…ĀĄŨ4ĐĶ´ĐĶJčQKö€Á‰ĄtÁ‹ÉĖ1˙5ŗŧuU5 tdĩK5 åé4ŒQ5 å{ĩ¨Šđ5 dI% 6>ŲP@CĄ‰BЊÆ9Ņ}O<‰ļ LË5B™æÂ48ō¯C€€8‡íNC€˙8‡íMŊ}Tæ8Ÿŧū:Ąkš@c!ĀĄsū6‡îI6KKˇ=K¨NÁŠÚoÁŒVŧ÷4Ņ´q}°4ō>4ĩ0BÜ4ķ'ų´â™4ņФĩmđ4ōĒžu 5͚[@C.~}BΔz9qįŌ<&Ķ;ëėũ>LË5#ûÂ3˙û8˞C€˙8| 7C€ū8| :Ŋ5ƒ8–n:Ô@ą@h›¸ĀĄę6*ŸÂ6JNKąēK8´ˆÁŒ|Á¨Ā*Ķuĩ1‰nĘ4ĖNŨĩx 4Ėų2”ô^4Ėí5āĻ4ĖÔ×Î 5ĢŠ§@C?ņŠBÍ m9-CI;ú—@;ą1É>LË}5ÖÂ48˛ķzC€ũ8s%ņC€ū8s%ņŊ§8^#:Ũ`!@n7ĸĀ Ũ95÷5åœuK3öKWøļÁnåÁ}iŗÄ43rŅ4ŗOų´5z4ŗönŗ—ˆ24˛ã}ĩ@ī4ŗ›L/ 4ÕP~@CS#KBËÔŽ>L˒5ČWÂ48 5tC€˙8ovUC€€8ovTŧĩÂ[@sö-Ā ¨Āŗ„33„3KROLK}ŦXÁŽÉkÁjCĩš#4cžķ4 u‚´´ĘĐ4Ą éŗZå”4 løŗĒ4Ą˙š 5eˆ@Ch@lBĘäõ>LËÆ4ä*šÂ3˙ũ8ņ×C€˙8i\C€ū8i\ŧ~Ķr7¯˙×:°Īl@yØ-Ā Ė4û´ûKvĘŲK•”hÁ-Á‘Ų—–)´“E94Žā´ÁĶM4ŽĄ/´ŗ,4Ž ­´h0A4Ž›Ä 4&ll@CzBĘ,:]ā<ãë< Ũ×>LĖ4ОoÂ3˙ũ8ēNC€˙8j¨ÉC€ū8j¨Ęŧ1bN7ĒÎĩ:ö‚=@Ū~Ā `6ÁĢl6´GnK‘dmK°ķPÁ‘™ŨÁ“N§€Ņ! ´š14ëj´›n04‚oą˛dú~4×X´ 14‚hG‘ 4i(˙@CŒƒ#Bɞm>LĖ4ŧĘâÂ3˙ú8qkC€˙8lŲ@C€ū8lŲBģõŒH7vũ;ĀD@ƒĀ G´˛ÖZ’2ÖZ’KĢéLĖ5üÂ3˙ú9ÄVC€˙8ņk„C€ū8ņk‹ģ¨=@†-ĖĀ 5ŗ† 3† KÁēKĶ›(Á”ßÁ”Ü[rŸŅcŗĖ#4ôĩ´á:Ē4ĩgO´lc&4Ī,ŗĩ˜c4Ę\,‘5„›@XTENSION= 'BINTABLE' / binary table extension BITPIX = 8 / 8-bit bytes NAXIS = 2 / 2-dimensional binary table NAXIS1 = 160 / width of table in bytes NAXIS2 = 69 PCOUNT = 0 / size of special data area GCOUNT = 1 / one data group (required keyword) TFIELDS = 40 TTYPE1 = 'SMA ' / label for field 1 TFORM1 = '1E ' / data format of field: 4-byte REAL TUNIT1 = 'pixel ' / physical unit of field TTYPE2 = 'INTENS ' / label for field 2 TFORM2 = '1E ' / data format of field: 4-byte REAL TTYPE3 = 'INT_ERR ' / label for field 3 TFORM3 = '1E ' / data format of field: 4-byte REAL TTYPE4 = 'PIX_VAR ' / label for field 4 TFORM4 = '1E ' / data format of field: 4-byte REAL TTYPE5 = 'RMS ' / label for field 5 TFORM5 = '1E ' / data format of field: 4-byte REAL TTYPE6 = 'ELLIP ' / label for field 6 TFORM6 = '1E ' / data format of field: 4-byte REAL TTYPE7 = 'ELLIP_ERR' / label for field 7 TFORM7 = '1E ' / data format of field: 4-byte REAL TTYPE8 = 'PA ' / label for field 8 TFORM8 = '1E ' / data format of field: 4-byte REAL TUNIT8 = 'deg ' / physical unit of field TTYPE9 = 'PA_ERR ' / label for field 9 TFORM9 = '1E ' / data format of field: 4-byte REAL TUNIT9 = 'deg ' / physical unit of field TTYPE10 = 'X0 ' / label for field 10 TFORM10 = '1E ' / data format of field: 4-byte REAL TUNIT10 = 'pixel ' / physical unit of field TTYPE11 = 'X0_ERR ' / label for field 11 TFORM11 = '1E ' / data format of field: 4-byte REAL TUNIT11 = 'pixel ' / physical unit of field TTYPE12 = 'Y0 ' / label for field 12 TFORM12 = '1E ' / data format of field: 4-byte REAL TUNIT12 = 'pixel ' / physical unit of field TTYPE13 = 'Y0_ERR ' / label for field 13 TFORM13 = '1E ' / data format of field: 4-byte REAL TUNIT13 = 'pixel ' / physical unit of field TTYPE14 = 'GRAD ' / label for field 14 TFORM14 = '1E ' / data format of field: 4-byte REAL TTYPE15 = 'GRAD_ERR' / label for field 15 TFORM15 = '1E ' / data format of field: 4-byte REAL TTYPE16 = 'GRAD_R_ERR' / label for field 16 TFORM16 = '1E ' / data format of field: 4-byte REAL TTYPE17 = 'RSMA ' / label for field 17 TFORM17 = '1E ' / data format of field: 4-byte REAL TUNIT17 = 'pix(1/4)' / physical unit of field TTYPE18 = 'MAG ' / label for field 18 TFORM18 = '1E ' / data format of field: 4-byte REAL TTYPE19 = 'MAG_LERR' / label for field 19 TFORM19 = '1E ' / data format of field: 4-byte REAL TTYPE20 = 'MAG_UERR' / label for field 20 TFORM20 = '1E ' / data format of field: 4-byte REAL TTYPE21 = 'TFLUX_E ' / label for field 21 TFORM21 = '1E ' / data format of field: 4-byte REAL TTYPE22 = 'TFLUX_C ' / label for field 22 TFORM22 = '1E ' / data format of field: 4-byte REAL TTYPE23 = 'TMAG_E ' / label for field 23 TFORM23 = '1E ' / data format of field: 4-byte REAL TTYPE24 = 'TMAG_C ' / label for field 24 TFORM24 = '1E ' / data format of field: 4-byte REAL TTYPE25 = 'NPIX_E ' / label for field 25 TFORM25 = '1J ' / data format of field: 4-byte INTEGER TTYPE26 = 'NPIX_C ' / label for field 26 TFORM26 = '1J ' / data format of field: 4-byte INTEGER TTYPE27 = 'A3 ' / label for field 27 TFORM27 = '1E ' / data format of field: 4-byte REAL TTYPE28 = 'A3_ERR ' / label for field 28 TFORM28 = '1E ' / data format of field: 4-byte REAL TTYPE29 = 'B3 ' / label for field 29 TFORM29 = '1E ' / data format of field: 4-byte REAL TTYPE30 = 'B3_ERR ' / label for field 30 TFORM30 = '1E ' / data format of field: 4-byte REAL TTYPE31 = 'A4 ' / label for field 31 TFORM31 = '1E ' / data format of field: 4-byte REAL TTYPE32 = 'A4_ERR ' / label for field 32 TFORM32 = '1E ' / data format of field: 4-byte REAL TTYPE33 = 'B4 ' / label for field 33 TFORM33 = '1E ' / data format of field: 4-byte REAL TTYPE34 = 'B4_ERR ' / label for field 34 TFORM34 = '1E ' / data format of field: 4-byte REAL TTYPE35 = 'NDATA ' / label for field 35 TFORM35 = '1J ' / data format of field: 4-byte INTEGER TTYPE36 = 'NFLAG ' / label for field 36 TFORM36 = '1J ' / data format of field: 4-byte INTEGER TTYPE37 = 'NITER ' / label for field 37 TFORM37 = '1J ' / data format of field: 4-byte INTEGER TTYPE38 = 'STOP ' / label for field 38 TFORM38 = '1J ' / data format of field: 4-byte INTEGER TTYPE39 = 'A_BIG ' / label for field 39 TFORM39 = '1E ' / data format of field: 4-byte REAL TTYPE40 = 'SAREA ' / label for field 40 TFORM40 = '1E ' / data format of field: 4-byte REAL TUNIT40 = 'pixel ' / physical unit of field TDISP1 = 'F7.2 ' / display format TDISP2 = 'G10.3 ' / display format TDISP3 = 'G10.3 ' / display format TDISP4 = 'G9.3 ' / display format TDISP5 = 'G9.3 ' / display format TDISP6 = 'F6.4 ' / display format TDISP7 = 'F6.4 ' / display format TDISP8 = 'F6.2 ' / display format TDISP9 = 'F6.2 ' / display format TDISP10 = 'F7.2 ' / display format TDISP11 = 'F6.2 ' / display format TDISP12 = 'F7.2 ' / display format TDISP13 = 'F6.2 ' / display format TDISP14 = 'G8.3 ' / display format TDISP15 = 'G6.3 ' / display format TDISP16 = 'G6.3 ' / display format TDISP17 = 'F7.5 ' / display format TDISP18 = 'G7.3 ' / display format TDISP19 = 'G7.3 ' / display format TDISP20 = 'G7.3 ' / display format TDISP21 = 'G12.5 ' / display format TDISP22 = 'G12.5 ' / display format TDISP23 = 'G7.3 ' / display format TDISP24 = 'G7.3 ' / display format TDISP25 = 'I6 ' / display format TNULL25 = -2147483647 / undefined value for column TDISP26 = 'I6 ' / display format TNULL26 = -2147483647 / undefined value for column TDISP27 = 'G9.3 ' / display format TDISP28 = 'G7.3 ' / display format TDISP29 = 'G9.3 ' / display format TDISP30 = 'G7.3 ' / display format TDISP31 = 'G9.3 ' / display format TDISP32 = 'G7.3 ' / display format TDISP33 = 'G9.3 ' / display format TDISP34 = 'G7.3 ' / display format TDISP35 = 'I5 ' / display format TNULL35 = -2147483647 / undefined value for column TDISP36 = 'I5 ' / display format TNULL36 = -2147483647 / undefined value for column TDISP37 = 'I3 ' / display format TNULL37 = -2147483647 / undefined value for column TDISP38 = 'I2 ' / display format TNULL38 = -2147483647 / undefined value for column TDISP39 = 'G9.3 ' / display format TDISP40 = 'F5.1 ' / display format IMAGE = 'synth.fits' END F’*˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙C€€˙˙˙˙C€~z˙˙˙˙Ãvô˙˙˙˙˙˙˙˙ÁÅ+˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙˙€€˙˙˙˙˙˙˙˙?__F‘^A’˜ÃBēßųB„#ø>“ü7>)ěÂ=ũœAžė›C€€=]?€C€~z=W‹(ĨPD R$?‰0?Y~`Á‡Œ;‹; ÉF’*F’*ÁÅ+ÁÅ+ŧ× h=äØģ‹zĢ=ÔVŊ•O.=§—Ôž °>­#^ =s z@?ĩĩF%˛Aąq™Bâ2BŸō>“ü7>)Ā Â=ū AžčUC€€=sYC€~T=mÄ ŠÍD$P?‰•å?^ŧüÁz€;,wŽ;,üF’*F’*ÁÅ+ÁÅ+ŧ× a=ä+ģË =ÔSPŊ•UĒ=§•Ģž Ģ5>­ =Šƒf@?!azFŖgAÖ´qCŲBÁˆG>“ü7>)ĘBÂ>ĨAžņæC€€=…āC€~*=‚jÄ­D'Zų?‰š[?dųÁjŠ;Q|;PäcF’*F’*ÁÅ+ÁÅ+ŧÖSû=ä5ģŽQž=Ô`,Ŋ•­˙=§÷ž ˛!>­" =ʎ@?1„ĶFŋBŲC%†&Bę>“ü7>)ŋÂ>üAžįkC€€=“9ųC€}ú=kŌÄ+D\D87?‰G?iœÁWk;~‘s;}­PF’*F’*ÁÅ+ÁÅ+ŧ֞j=ä‘ģŽ˛:=ÔQëŊ•›ī=§ÖĀž ¨Z>­ Ū =ī+ŋ@?CEOF G B)#CHWcC ŠŠ>“ü7>)ÉåÂ>īAžņC€ū=ĄũĩC€}É=ÍÄ6Á@;šî2;šDoF’*F’*ÁÅ+ÁÅ+ŧÕuS=ä ģ_=Ô`TŊ•Āã=¨ æž ą/>­"4 > U@?VĖ>F `B>?CrY\C+]Ū>“ü7>)ˇœÂ=ųŪAžāpC€€=˛ÁC€}‹=­„āÄO@ĮD^ˇz?‰Œį?uÁ#‹;ŧŗš;ģģŦF’*F’*ÁÅ+ÁÅ+ŧ×^Î=äÖģŒë¨=ÔF(Ŋ•)=§^üž Ŗž>­Š >6™‘@?lGF HĐBfbC’ĸ—CO_•>“ü7>)ÃēÂ=˙EAžëČC€€=ÃųC€}N=žë5ÄcīDuQ?‰–6?zë’ÁØ;æG‰;äŅÆF’*F’*ÁÅ+ÁÅ+ŧ× )=äËģ•|=ÔV`Ŋ•l =§­Đž ­Í>­< >;t@?ķãF öũB‹6ˆCąvdCzøF>“ü7>)Į{Â>ÔAžīLC€ū=טĪC€} =ŌĐÄzŋûD†Æá?‰™H?€|CÁփ< Ë < ŦôF’*G5ö´ÁÅ+Á:ēŸŧÕø=äķģ´=Ô\TŊ•ĸŊ=§ęģž ¯ô>­u >jöq@?Žō­FXãB¨ČĒC×(tC˜#Ã>“IÕ=ë7ÁÂ=dĮA]3C€ö=ŖīÄC€|ŧ=ŸûLÄĮŨzDŽY’?6T¯?ƒ•kÁĸ<,á-<+3”F’*G5ö´ÁÅ+Á:ēŸŧ—rb=ší×ģ]ĸk=’ŧÎŊEú)=N$ž^c >4€ ?Öũ@?>%FąBĻÎ{CÔŖ2C–[>wcA=… Â7'eAūC€€Ô=BoC€~ļ=A'KÅ(éŪD`|r>Ē­?†Á˛Áĩ<0t}<.ŧŦG5ö´G5ö´Á:ēŸÁ:ēŸŧ,‹Ô="šĀģ´ĩI=āßŧˇo5SDŠ=(Î*Â;Ô @Ķ+IC€€˙=ZC€Z=–ÅHŖ}Dčß>JÁÆ?АÁ,]<ū~<ĖÖG5ö´G5ö´Á:ēŸÁ:ēŸģ‡ T<ĨWģ‹N<Ŋ|ŧ€:6<+¸<Ŋ….Œ<ƒTĪ > Ę1@?žC„Eëü;BNxdCƒ™xC:>Géų<íēŋÂ:d@œ,ŌC€:<ËôC€R<ČõRÅMV$CãB> ĒM?U}Á;ô";ōYĶGrâ‡G’ŽÁ?žáÁC‘ ģ!ē<ˆĻņģņ<ƒ[ŧŧRį\< ÅĩŊ-wĶ<#} ={Ō@?ŅJEEÜ6MB*Á,CYĢ”Cę…>C$ô<ˇŲ~Â9ī+@vĶ3C€€č<Ŧ >C€(<ĒušÅHŞCĨËí=ĶgB?ŊöÁën;Ø-Ž;ÖßyGrâ‡G’ŽÁ?žáÁC‘ ģo‘™<ũi<„^Â8ōĮ@6‚˙C€&<‡A`C€S<†C=Å=šųCPĒÃ=ŒŪG?”;xÁ‚Ė;­Ė;Ŧŋ‰G’ŽG’ŽÁC‘ÁC‘ 쀿€<ĄgēËj<Ōŧ ‘B;°uŧŦ{Ž;­đB =Âũ@?ũ=ĩEš@A’ÍBģ"ˇB„S*>5K<ˇIÂ7Β?Öŋ•C€<<'ĶC€Ņ<&ĒÅ2ČC\Ë=O|Ą?—Î…ÁæÚ;\Ėh;\^G’ŽG’ŽÁC‘ÁC‘ ģžÍą;˜b:;—5TģŊYĩ;‡>Uģ˛1; =L\@@ HWEĻ:āAĒBØČB™Iū>*ũ;áËG’ŽGģ+ŋÁC‘ÁGBĨ ŧW<;Ûã3ēęĀe;Ų_qēmåj;†đ<‡Ą^;ˆD @u`˜@@5úE˜úŸAūôBKˇĢB Ė>5 ;×[ņÂįXē@@(ˆ-EŒãÕA-5˙Bm-ĮB'ĩô>?ņ<Â1Ûę?ާ˜C€€.<@ĸwC€€üCŽ#@@9b˜EËė@ôR1B2úAû×#>Dœ;Ÿę(Â6X§?WXŽC€é<’ C€€f<:ČÄŽĸˇAūmŧ<ē|?ĻüÆÁŧ;ęŌ;¯įH Ē3H?QÁN5ÁNåv9Îŋ™;=xÂēŗ;5 ēŠ:K;˙ģÍÃ;3 >ŨH@@KėtEiĐā@}AÔ;ŦA–K>AM|;Y øÂ3 Ÿ?ĖâC€ũ;ÁŦ?C€ų;Áí‹Ä‰ A´8<¨K?Ģ›Áëv:¨[ :¨ ŊH Ē3H9´ÁN5ÁSI%¸Ŗâš:ųÛu¸¨ĸ:ų~Í:ŊŅ::į˜;T§:æė& =ŋŲį@@`PæESĖĻ@Ą'mAūΎA´,ø>AËą;‹Ą(Â8ļˆ?97rC€€š< =GC€ų< gmÄW˙A’ <­M?¯#KÁ 3¨:Ķ—):ĶcH,ëÃH9´ÁQíÁSI!%ēy]Í;#EÕšƒ‰;!C`ēK(t;āĐ;Žãf;, >Lėß@@vŋdEAC@'ĸjAŠūÁAD‘>F ;M Â4ŧK>ŋÃ)C€ē; ÅąC€ũ; žiÄ=ĐøAģJF÷o:Ö­āÂ18>ŠkZC€€;€!ßC€€;€žLÄ&ŧ@Ųf˛<'Í?ˇ¯§Á âŽ:<°v:<ËHEãŽHn$ÁTDÔÁW|J)9:P(:}}¸ 6ų:{ĩ<:cŌ(:j*ķēŊF´:h–H =U@@•HFE`@ qAld_A''‰>G˛Æ; ;ŽÂ3îl>°[@C€ü;ļũC€€;ļžÄ…ô@ĸßŨ<"6?ŧ‘Á Đ:gÅė:gf™HtDKH…”ÁWí4ÁYjŽ;E9ėŽ:Ŗ÷EšĀˇÁ:Ĩ3Ÿ7"B°:”Ēö;'‹:•І2?S€@@¤5įE–œ?Š™Ađé@ē—Ä>I­7:”v™Â36 >=>ĀC€ō;W*ũC€>;WfÚÃä-@HIp:ŒTFÂ2ëĨ>4\ôC€€S;_ĢzC€€I;_ūŌÃļ×Ü@ ž;Å Ũ?ÅLÁn'9ū]Ē9ūmęH“¸5H e˜Á[;Á\Š9Sa9´ô:)fš×*:'^žšFß:3:˛ÄŸ:‡u =8ä‡@@ÆąŪDæ6ú?$}ņ@ŦĄ@sIq>JdÂ:R”fÂ49Ą>ƒ C€€ĸ;8¯wC€ē;8 šÃ›Vk? ē;„q5?ĘÁ›ã9Æßu9ÆWęHĄįH´^Á\ēÁ^ŗ ayē2Ë;9÷ø˜8=>Ĩ9ö¯č¸4į`9æžē‚# 9ää3# <=ē@@ڐuDÍũÕ>ČsÛ@]K+@zm>I¸: ÜfÂ3žÉ=ļŽ?C€€+; yđC€Ŋ; …ŽÃ}æP?;R0;<Ūá?ÎíSÁ­u9†ū‚9‡uĖHŗĖHÆgãÁ^žÁ`Zúw‘ēLô9ĻĻ’š=ā9ŖÂˇš‹9ĄMc9öN9ž‹u' ;Ú71@@đk´D¸{i>^Ŗ_?˙f?´[‡>JëŠ9Š&JÂ4’ĩ=TK‚C€å:˛ųĸC€ų:˛Ķ7ÃRnÛ?ÖĖ;ŧÜ?ĶęĩĀũ†9'ž?9'ĖNHÂÖųHÛ´=Á` QÁb q‹ą9‘ā9KŨ`7M°9Lxũ¸Ū¨Ĩ9@x;šĘ9A * ‰€A@$۟?é$ī>KeW9æ26Â3“Ļ=‘˛C€€;Ē[C€×;ĀÃ.B>Ė; W?ŲåĀų™,9hŪ9gāHŨÎŪHõzĀÁbJÔÁd ÆŗŨš¤5B9‰GŠ7mZ9Š> 9Œw˙9ōaš˙˜d9‚čŸ. ;r{ö@At]D’ĀŠ>;|+?ė°1?§]&>K 9¯PMÂ3ā‘=\´C€é:ā¸gC€ĩ:ā‰à °Ī>ƒģ4:î~?ŪB Āõ’î91'91žØHđËKI(äÁc¸Áex%Ķšg˙9PÜ9='9OŪhĩpŧ9IÁÖ9Žž9I˛/3 ;*Ią@A D‚ŦX>d–?Á¨Ũ?ˆđ#>KFˇ9—˙Â4ķ==ĄC€đ:ÔÖÖC€€:Ô՞Âč€>:Z:ͅE?㞊ĀņŠÉ9ÎH9ЉIĨIFáÁeECÁg÷ũ=8q-98ĪôˇãšQ98ūĪ67ŖG947b9”ą94w8:‰JØ@A0Dhh„=Ų&?—!?Uē–>K°M9tÄ/Â4C&=kßC€€:ž`œC€€:žNžÂŊŠ =ûCã:Š“š?éČĀíw9°ķ9ú°IŦYIÍåÁfĘÁh‡e/y86-Î9s§¸įÍ'9•Ĩ¸„Š9ÕÛ8šŋŠ9L> ;;k@AA™šDNXy=¤ÂÍ?p-¤?)Ôę>KÅú9PˆÂ44=ĐdC€€ :ąģC€˙:ąĩ#™ČĶ=§õ„:‹ĖH?îēÉĀéU8Ũŧ8ŪwI÷¯I/9ÁhS¨Áj8°mҏ™@9]íˇ“§:8˙ũŨ¸Ÿ‰Á8ø#ŋ9{ߛ8÷ƒŽD :đß^@ATõÃD7{=lˇE?3ü_>ū‰Ô>KÖ|9.#WÂ4)r<؛C€€5:ĸæ…C€ë:ĸÜUÂyBz=Ph}:V 7?ô|}Āå-Õ8ŗV8´I,) I=PÁiî”Ák™hŊ)ši8ŌŊaˇŪf8Ôh7¸"9-8Éū9{Tņ8ËŦŸJ :psÔ@AjAŠD"bŦ=”/>×ng>˜U9>Lô8×FcÂ47<†ĖũC€÷:^F8C€ü:^CåÂIĶ<éÂ:°?úaēĀá18e„)8fë„I˛Ã˛>|Τ>LMX8Ŧ(ĮÂ4ˆ”é´>R˜;>LDĸ8ēÜŪÂ4ßYëã>ū>L¤8~ķXÂ4<(ęC€Ų:/*đC€ü:/(ãÁĪÄt<uE9ļė@wĢĀԃÅ8ņŅ8pIqÁtI…j¨ÁoÔXÁq‹Cĩą8—KĶ8mˇ:58M ¸V:8/o¸¸”Ŗ8rÂm :LI}@AĢ|ŗCČũë<,ß|=Ø[=˜Ėą>M˙j8’’Â4 ī<6KMC€ø:]XÆC€€:]VOÁ¤Ēˇ?$Ę/=v@ ĩĀĀĐV17ņ97ív I„5{Iœ^ÁqbĶÁrņ’ЏÅG81˜a7‰y81ûû7]4Ė8$ÔZ¸ā* 8&š2 :7Nå@AŧĸÅC˛áu<.=ģŸz=„Ģb>Nˇ8~yōÂ4Ė<ŋC€đ:TZC€˙:TQÁuŲ>Ōtķ<Đ4@ ŲĀĖI˜7čŊˇ7égąIIœĩ%ÁrÂ~ÁtVÖ{Õ5Ū!—8`˙5’ë8ŊW8^ËÛ8ĸʸxļ;84“2 9Ǐ@AĪŲCŸŸ >$ÔT@Ov?‘°Ŗ>MĢ–:—@HÂ4Ė><īC€đ<Š˜wC€˙<Š—ÉÁG‚Ė>‘‘@<ēČs@nrĀČTi:‹Ë:~ëI›ßIŠ*tÁt'ÁuĢ -5Äad:6uø:m‰Ö:7išĀ?9:'+ã:ö:(:ž22>|be@LÍÚAä?ÕCŽ‹x>jŨ@Žņƒ?Ī—”>JĨ‡:ôūÂ4Ė>šfčC€<õīžC€€î<õî‡Á!ú >UjĒ<¨Ĩē@ę ĀÄf:e•:dŪ/I¨HūI¸[ÎÁu“ŌÁw)‚ ē^Râ:”Mįš†Úr:•D!9ޏY:}j:ũ_“:ļ'2 >9\ä@ōÂzAûC€Õ> Ū @; Î?yI>JĨ‡:ĒÖdÂ4Ė>WU§C€€Â<ŧĨBC€-<ŧ¤RĀú÷…>ē–<’œ€@{ ĀĀŽh:Ũˇ:؅IļXÆIĮãdÁvøļÁx‘- ¯ :qŪú:Må9áö:Nk§9Oa:LTšĀRf:Mņ22>K‘ AnŪB BCg€Š>b—@_%?‹i&>Lgc:Öw8Â3}>†¤^C€Ė=C€€#=Ž`ĀÂnÄ=Ã×+<€íC@";ĀŊ*:4¤E:4|IIÅúYIŲ"ôÁxfxÁzB ŊĄšsÖ]:ƒ ã8§ã7:„bšOųa:rÉģ:Uƒ:sTy7 =°ĄUA#íĶBæbCQÜ]=Æ,T@!ąÆ?Ay>Lœ›:ĨxÂ3×#>QTņC€Ė<ŪągC€€#<ŪēĀ•Î=Šv,Ĩ:>BI֛Ië‘ÁyÍÁ{k'šoņÖ:M;­šEo:L9\@:L(.ē $:KH= =[ļA2Ī9B'C?ތ=]A”?Ƨî>âaę>MIm:TgŠÂ4Ô>âjC€G<CéC€ž<AdĀlU='‡<5Š4@"´ŖĀļ›B9 tO9 ^™IéÎJqÁ{ļ­_>MIm:A9ŨÂ4Ô=ņISC€€ĩ<œĸ×C€ <œ #Ā4ęŲ<ÜĐé<:@&Ą ĀŗĒ 9‡š9†æŠIũ™âJ .Á|ŗŸÁ~n$ĢÕˇąė„9î1&š"ã09īO|9 eP9ëv"ē “79ėāĢI =Ērs>LÛg:I%pÂ3ö4=ũŽ™C€ž<´ûC€õ<´­Ā …™<˛<ˆ@*Ĩ¨Ā°íÚ9T”9YˆJ aŠJz˜Á~8IÁ€f ]ˇÕl–9úkŊˇX199ųb¸‹‡9úAl7fÕ9ųązQ :âÜæAo†ƒB^eCũ=´5?ĄTC>žŲl>Lôœ:UÆėÂ3ö4>ĻC€ž<Ō[vC€õ<Ō]tŋÔÜY<_ }<y[@.ÃĀŽuē9w=9všJpuJ(w•ÁÉNÁ€Ņ–]%ų8SM:B7Ŧ{:ÉW8xŧ:4úēÉĩ: hY <ŗ“ŌA„BtĸėC<2<ˆî?5(:>)q9>Lôœ:õÂ3ö4=ĸž,C€ž<‹Ė¤C€õ<‹Íôŋ Æ$< Qž;Ú§@2ųįĀŦ=•9×g9ĄīJ& ĀJ9€GÁ€˛Á§Ä$Å-ņ8ŸÁŸ9 ūķ¸ģØF9 o”8Ÿ T9 Éo¸Š09 lUb2=Vį˜A’OĨB†ŒĪCnÄ>/>Mcë: ĐËÂ3ö4=­‡C€ž<ĨzâC€õ<Ĩ|ĸŋsÎŲ;ļmī;ŋV@7JžĀĒGČ8ũˇ8üJ6qKJM8{Á‚ĖÁ‚ˆg,S7Š8´x9ŦŽM´ęō9­ī˜7P-z9Ģ™H9ÅD9ŦˇŨj <¯Ū9AŖ€UB”JB˙ǎ÷Ĩ>KđÁ: ģķÂ3ö4=ŽL’C€€0<ˇC€c<ˇSŋ8 ;n-Ī;Ĩ—S@;ļ9ˆ8ÜßB8ÜČrJIĖyJc†ÜÁ‚cÁƒmÖ5ĮC)¸8Û˙9­)¤ˇ=÷E9ŽķÜ6%íģ9­Ä9%9­úōq ;Î4A¸Ę˜BĸÎ8BôËÚ<…? ķ=Μ`>L˜:Ģ=Â3ö4=žäaC€€j<¸*SC€Ą<¸-ŋ DT;#€;˜´N@@<ũ§Ą8Ŧ @8Ģũ™J_æßJ~ĩÁƒJ Á„cAQiļ ÷9Ÿ'z8—Ų9žųI8(šÂ9˜0:€×d9—4*w ;ļAÔI%Bŗ BëōŽ;žûļ>¸a=„aĩ>LÆŖ9ÄÔ9Â3ö4=qLļC€€j<š|C€Ą<š~ÉžĖÁ:ĘFÂ;|æ˙@D߲ĀĨž/8aĢ08`T„Jy‰-JŽetÁ„;'Á…aNŋbi8™~y9rŖŲļĒ6…9qœa¸ŦIŠ9ož*9ĢNq9pTĀ{ ;dįĀAøLĪBÄūĻBäĢ{;}Ŧ:>†ė™=1÷„>LŽ]9“øÂ3ö4=8‘ÅC€[<ŧC€€Ž< ž—",:wÃ;X]?@IŸ¤§8(8J‹ĶVJ Ŧ_Á…8‚Á†m‰_Yw=8‡-k97à 8>› 97EH8øÛ97Coš€96Åč~ ;¨aDB$ÄBØąˇBŪžÚ;#}ë>?NŲ<åf">LŽ]9‘fÂ3ö4=5\úC€€Y<‹p)C€ą<‹rŋž^Y:;60@N{ĻĀŖž 7Íče7Ę!ėJcĄJļ !Á†?Á‡ƒ'sG)¸ ęc94§1¸"Øē94+‘ˇūÚĢ94–h¸.žC94Ą~ ;š B2 ŲBî]BŲķ/:úEr>! :<¯”œ>M%9‚ŠãÂ4:D="ŋ4C€€Y<‰č9C€ą<‰Øūž#ŒÎ9Ņ:Ž;#ŋņ@SvJĀĸüt7 ēm7žßJ˛…JĪdEÁ‡R™ÁˆĨ‹[ŽMļs{9"uĸˇ¨=9"v7;ģ9"K…¸ +Ã9!ÜN~ <÷€BWX)C™BÖō:ø÷5>079<ŽĒ>M%9uuÂ3ä™=ŨC€€Y<ŽpŋC€ą<Žx&Ŋîo69„ā&;Ē#@XĒĀĸ\Û7ĸ7Ą;ŖJĘÚÆJíÅzÁˆsäÁ‰Õ ¨ąŌũˇāÖ39wŽ8ƒÄW9:8đB9‹ā9 -p9$~ ;FĸõB‚H›C5ÂBŌ÷ō:–ŋĨ=ę LŨĨ9ŽĪÂ4Č<ĮsC€€YLÚ,9fÂ3û­<ÄQŽC€S<].éC€€ˇ<]0¸Ŋwņ8Õ -:ÛųĘ@c!ĀĄrA2–Ŋ˛–ŊKļlK¨NÁŠÚaÁŒVŧ÷ 4Ņ8ŋ8ðČ5cįI8Ã*ÛĩŊmÛ8ĀÛbš7ƒ8ĀWq~ :†ŠBžČvC.~}B΍<:îb0>`–Ž<§=”>Lį§9 é[Â3û­<ȄČC€€ -_LÁĀ8ëäÂ3û­<’Ü„C€€ ¸ģ<(EV>LËp8ã˛"Â3û­<đEC€€ U÷)Láæ8ŌØÂ3ūÉ<‚čC€ä@yØ-Ā 87hpe7lYKvÂėK•”hÁ,ÖÁ‘Ųƒ–)7 8‚”j5+oü8‚; ļÉt`8‚>8N Ž8äņ~ 8ZÚŪCLyqCzBĘ):ŽŊ>pŨĪLŨĨ8š¨Â3ūÉLŋâ8Ĩ1DÂ4<–ĖC€õ<}XTC€Û<}UŅģđŲĶ@ƒĀ GYŗņũL3ņũLKĢęĪKÆ …Á“|Á”I ]ŒKļė7l8‹ė5û*8OšĶˇnDl8m]Ö8)k8lzqK3 7ķRQC•ļCš@BÉ1';/ ‡>¨°ƒ<Ė­>Lŋâ8€ ‹Â4<¯[C€õ<‹ōC€Û<‹īũģĨ`…8ĩ2Ē<Œ>Ä@†-ĖĀ 4Ú7đ?¯7ķG{KÁįKĶ›(Á”íÁ”Ü[rĨŅcļf 8Hōę7ŧV8H= 1.0: aux = saux return abs(a**2 * (1.0 - eps) / 2.0 * math.acos(aux)) def test_angles(phi_min=0.05, phi_max=0.2): a = 40.0 astep = 1.1 eps = 0.1 a1 = a * (1.0 - ((1.0 - 1.0 / astep) / 2.0)) a2 = a * (1.0 + (astep - 1.0) / 2.0) r3 = a2 r4 = a1 aux = min((a2 - a1), 3.0) sarea = (a2 - a1) * aux dphi = max(min((aux / a), phi_max), phi_min) phi = dphi / 2.0 phi2 = phi - dphi / 2.0 aux = 1.0 - eps r3 = a2 * aux / np.sqrt((aux * np.cos(phi2))**2 + (np.sin(phi2))**2) r4 = a1 * aux / np.sqrt((aux * np.cos(phi2))**2 + (np.sin(phi2))**2) ncount = 0 while phi < np.pi * 2: phi1 = phi2 r1 = r4 r2 = r3 phi2 = phi + dphi / 2.0 aux = 1.0 - eps r3 = a2 * aux / np.sqrt((aux * np.cos(phi2))**2 + (np.sin(phi2))**2) r4 = a1 * aux / np.sqrt((aux * np.cos(phi2))**2 + (np.sin(phi2))**2) sa1 = sector_area(a1, eps, phi1, r1) sa2 = sector_area(a2, eps, phi1, r2) sa3 = sector_area(a2, eps, phi2, r3) sa4 = sector_area(a1, eps, phi2, r4) area = abs((sa3 - sa2) - (sa4 - sa1)) # Compute step to next sector and its angular span dphi = max(min((sarea / (r3 - r4) / r4), phi_max), phi_min) phistep = dphi / 2.0 + phi2 - phi ncount += 1 assert 11.0 < area < 12.4 phi = phi + min(phistep, 0.5) # r = (a * (1.0 - eps) / np.sqrt(((1.0 - eps) * np.cos(phi))**2 + # (np.sin(phi))**2)) assert ncount == 72 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/tests/test_ellipse.py0000644000175100001660000001344614755160622023266 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the ellipse module. """ import math from contextlib import nullcontext import numpy as np import pytest from astropy.io import fits from astropy.modeling.models import Gaussian2D from astropy.utils.exceptions import AstropyUserWarning from photutils.datasets import get_path, make_noise_image from photutils.isophote.ellipse import Ellipse from photutils.isophote.geometry import EllipseGeometry from photutils.isophote.isophote import Isophote, IsophoteList from photutils.isophote.tests.make_test_data import make_test_image from photutils.tests.helper import PYTEST_LT_80 # define an off-center position and a tilted sma POS = 384 PA = 10.0 / 180.0 * np.pi # build off-center test data. It's fine to have a single np array to use # in all tests that need it, but do not use a single instance of # EllipseGeometry. The code may eventually modify it's contents. The safe # bet is to build it wherever it's needed. The cost is negligible. OFFSET_GALAXY = make_test_image(x0=POS, y0=POS, pa=PA, noise=1.0e-12, seed=0) class TestEllipse: def setup_class(self): # centered, tilted galaxy self.data = make_test_image(pa=PA, seed=0) @pytest.mark.remote_data def test_find_center(self): path = get_path('isophote/M51.fits', location='photutils-datasets', cache=True) hdu = fits.open(path) data = hdu[0].data hdu.close() geometry = EllipseGeometry(252, 253, 10.0, 0.2, np.pi / 2) geometry.find_center(data) assert geometry.x0 == 257.0 assert geometry.y0 == 258.0 def test_basic(self): ellipse = Ellipse(self.data) isophote_list = ellipse.fit_image() assert isinstance(isophote_list, IsophoteList) assert len(isophote_list) > 1 assert isinstance(isophote_list[0], Isophote) # verify that the list is properly sorted in sem-major axis length assert isophote_list[-1] > isophote_list[0] # the fit should stop where gradient loses reliability. assert len(isophote_list) == 69 assert isophote_list[-1].stop_code == 1 def test_linear(self): ellipse = Ellipse(self.data) isophote_list = ellipse.fit_image(linear=True, step=2.0) # verify that the list is properly sorted in sem-major axis length assert isophote_list[-1] > isophote_list[0] # difference in sma between successive isohpotes must be constant. step = isophote_list[-1].sma - isophote_list[-2].sma assert math.isclose((isophote_list[-2].sma - isophote_list[-3].sma), step, rel_tol=0.01) assert math.isclose((isophote_list[-3].sma - isophote_list[-4].sma), step, rel_tol=0.01) assert math.isclose((isophote_list[2].sma - isophote_list[1].sma), step, rel_tol=0.01) def test_fit_one_ellipse(self): ellipse = Ellipse(self.data) isophote = ellipse.fit_isophote(40.0) assert isinstance(isophote, Isophote) assert isophote.valid def test_offcenter_fail(self): # A first guess ellipse that is centered in the image frame. # This should result in failure since the real galaxy # image is off-center by a large offset. ellipse = Ellipse(OFFSET_GALAXY) match1 = 'Degrees of freedom' ctx1 = pytest.warns(RuntimeWarning, match=match1) if PYTEST_LT_80: ctx2 = nullcontext() ctx3 = nullcontext() ctx4 = nullcontext() else: match2 = 'Mean of empty slice' match3 = 'invalid value encountered' match4 = 'No meaningful fit was possible' ctx2 = pytest.warns(RuntimeWarning, match=match2) ctx3 = pytest.warns(RuntimeWarning, match=match3) ctx4 = pytest.warns(AstropyUserWarning, match=match4) with ctx1, ctx2, ctx3, ctx4: isophote_list = ellipse.fit_image() assert len(isophote_list) == 0 def test_offcenter_fit(self): # A first guess ellipse that is roughly centered on the # offset galaxy image. g = EllipseGeometry(POS + 5, POS + 5, 10.0, eps=0.2, pa=PA, astep=0.1) ellipse = Ellipse(OFFSET_GALAXY, geometry=g) isophote_list = ellipse.fit_image() # the fit should stop when too many potential sample # points fall outside the image frame. assert len(isophote_list) == 63 assert isophote_list[-1].stop_code == 1 def test_offcenter_go_beyond_frame(self): # Same as before, but now force the fit to goo # beyond the image frame limits. g = EllipseGeometry(POS + 5, POS + 5, 10.0, eps=0.2, pa=PA, astep=0.1) ellipse = Ellipse(OFFSET_GALAXY, geometry=g) isophote_list = ellipse.fit_image(maxsma=400.0) # the fit should go to maxsma, but with fixed geometry assert len(isophote_list) == 71 assert isophote_list[-1].stop_code == 4 # check that no zero-valued intensities were left behind # in the sample arrays when sampling outside the image. for iso in isophote_list: assert not np.any(iso.sample.values[2] == 0) def test_ellipse_shape(self): """ Regression test for #670/673. """ ny = 500 nx = 150 g = Gaussian2D(100.0, nx / 2.0, ny / 2.0, 20, 12, theta=40.0 * np.pi / 180.0) y, x = np.mgrid[0:ny, 0:nx] noise = make_noise_image((ny, nx), distribution='gaussian', mean=0.0, stddev=2.0, seed=0) data = g(x, y) + noise ellipse = Ellipse(data) # estimates initial center isolist = ellipse.fit_image() assert len(isolist) == 54 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/tests/test_fitter.py0000644000175100001660000001565714755160622023134 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the fitter module. """ import numpy as np import pytest from astropy.io import fits from numpy.testing import assert_allclose from photutils.datasets import get_path from photutils.isophote.fitter import CentralEllipseFitter, EllipseFitter from photutils.isophote.geometry import EllipseGeometry from photutils.isophote.harmonics import fit_first_and_second_harmonics from photutils.isophote.integrator import MEAN from photutils.isophote.isophote import Isophote from photutils.isophote.sample import CentralEllipseSample, EllipseSample from photutils.isophote.tests.make_test_data import make_test_image DATA = make_test_image(seed=0) DEFAULT_POS = 256 DEFAULT_FIX = np.array([False, False, False, False]) def test_gradient(): sample = EllipseSample(DATA, 40.0) sample.update(DEFAULT_FIX) assert_allclose(sample.mean, 200.02, atol=0.01) assert_allclose(sample.gradient, -4.222, atol=0.001) assert_allclose(sample.gradient_error, 0.0003, atol=0.0001) assert_allclose(sample.gradient_relative_error, 7.45e-05, atol=1.0e-5) assert_allclose(sample.sector_area, 2.00, atol=0.01) def test_fitting_raw(): """ This test performs a raw (no EllipseFitter), 1-step correction in one single ellipse coefficient. """ # pick first guess ellipse that is off in just # one of the parameters (eps). sample = EllipseSample(DATA, 40.0, eps=2 * 0.2) sample.update(DEFAULT_FIX) s = sample.extract() harmonics = fit_first_and_second_harmonics(s[0], s[2]) _, a1, b1, a2, b2 = harmonics[0] # when eps is off, b2 is the largest (in absolute value). assert abs(b2) > abs(a1) assert abs(b2) > abs(b1) assert abs(b2) > abs(a2) correction = (b2 * 2.0 * (1.0 - sample.geometry.eps) / sample.geometry.sma / sample.gradient) new_eps = sample.geometry.eps - correction # got closer to test data (eps=0.2) assert_allclose(new_eps, 0.21, atol=0.01) def test_fitting_small_radii(): sample = EllipseSample(DATA, 2.0) fitter = EllipseFitter(sample) isophote = fitter.fit() assert isinstance(isophote, Isophote) assert isophote.ndata == 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=(36.0 / 180.0 * np.pi)) fitter = EllipseFitter(sample) isophote = fitter.fit() assert isophote.ndata == 119 assert_allclose(isophote.intens, 685.4, atol=0.1) # last sample taken by the original code, before turning inwards. sample = EllipseSample(self.data, 61.16, eps=0.219, position_angle=((77.5 + 90) / 180 * np.pi)) fitter = EllipseFitter(sample) isophote = fitter.fit() assert isophote.ndata == 382 assert_allclose(isophote.intens, 155.0, atol=0.1) def test_m51_outer(self): # sample taken at the outskirts of the image, so many # data points lay outside the image frame. This checks # for the presence of gaps in the sample arrays. sample = EllipseSample(self.data, 330.0, eps=0.2, position_angle=((90) / 180 * np.pi), integrmode='median') fitter = EllipseFitter(sample) isophote = fitter.fit() assert not np.any(isophote.sample.values[2] == 0) def test_m51_central(self): # this code finds central x and y offset by about 0.1 pixel wrt the # spp code. In here we use as input the position computed by this # code, thus this test is checking just the extraction algorithm. g = EllipseGeometry(257.02, 258.1, 0.0, 0.0, 0.0, 0.1, 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.ndata == 1 assert isophote.intens <= 7560.0 assert isophote.intens >= 7550.0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/tests/test_geometry.py0000644000175100001660000001267314755160622023465 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the geometry module. """ import numpy as np import pytest from numpy.testing import assert_allclose from photutils.isophote.geometry import EllipseGeometry @pytest.mark.parametrize(('astep', 'linear_growth'), [(0.2, False), (20.0, True)]) def test_geometry(astep, linear_growth): geometry = EllipseGeometry(255.0, 255.0, 100.0, 0.4, np.pi / 2, astep, linear_growth) sma1, sma2 = geometry.bounding_ellipses() assert_allclose((sma1, sma2), (90.0, 110.0), atol=0.01) # using an arbitrary angle of 0.5 rad. This is to avoid a polar # vector that sits on top of one of the ellipse's axis. vertex_x, vertex_y = geometry.initialize_sector_geometry(0.6) assert_allclose(geometry.sector_angular_width, 0.0571, atol=0.01) assert_allclose(geometry.sector_area, 63.83, atol=0.01) assert_allclose(vertex_x, [215.4, 206.6, 213.5, 204.3], atol=0.1) assert_allclose(vertex_y, [316.1, 329.7, 312.5, 325.3], atol=0.1) def test_to_polar(): # trivial case of a circle centered in (0.0, 0.0) geometry = EllipseGeometry(0.0, 0.0, 100.0, 0.0, 0.0, 0.2, 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, 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, 0.2, linear_growth=False) # sector at 45 deg on circle vertex_x, vertex_y = geometry.initialize_sector_geometry( 45.0 / 180.0 * np.pi) assert_allclose(vertex_x, [65.21, 79.70, 62.03, 75.81], atol=0.01) assert_allclose(vertex_y, [62.03, 75.81, 65.21, 79.70], atol=0.01) # sector at 0 deg on circle vertex_x, vertex_y = geometry.initialize_sector_geometry(0) assert_allclose(vertex_x, [89.97, 109.97, 89.97, 109.96], atol=0.01) assert_allclose(vertex_y, [-2.25, -2.75, 2.25, 2.75], atol=0.01) def test_area2(): # circle with center at 100.0, 100.0 geometry = EllipseGeometry(100.0, 100.0, 100.0, 0.0, 0.0, 0.2, linear_growth=False) # sector at 45 deg on circle vertex_x, vertex_y = geometry.initialize_sector_geometry( 45.0 / 180.0 * np.pi) assert_allclose(vertex_x, [165.21, 179.70, 162.03, 175.81], atol=0.01) assert_allclose(vertex_y, [162.03, 175.81, 165.21, 179.70], atol=0.01) # sector at 225 deg on circle vertex_x, vertex_y = geometry.initialize_sector_geometry( 225.0 / 180.0 * np.pi) assert_allclose(vertex_x, [34.79, 20.30, 37.97, 24.19], atol=0.01) assert_allclose(vertex_y, [37.97, 24.19, 34.79, 20.30], atol=0.01) def test_reset_sma(): geometry = EllipseGeometry(0.0, 0.0, 100.0, 0.0, 0.0, 0.2, 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, 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, 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, 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, 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, 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, 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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/tests/test_harmonics.py0000644000175100001660000001664314755160622023616 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the harmonics module. """ import numpy as np from astropy.modeling.models import Gaussian2D from numpy.testing import assert_allclose from scipy.optimize import leastsq from photutils.isophote.ellipse import Ellipse from photutils.isophote.fitter import EllipseFitter from photutils.isophote.geometry import EllipseGeometry from photutils.isophote.harmonics import (first_and_second_harmonic_function, fit_first_and_second_harmonics, fit_upper_harmonic) from photutils.isophote.sample import EllipseSample from photutils.isophote.tests.make_test_data import make_test_image def test_harmonics_1(): # this is an almost as-is example taken from stackoverflow npts = 100 # number of data points theta = np.linspace(0, 4 * np.pi, npts) # create artificial data with noise: # mean = 0.5, amplitude = 3.0, phase = 0.1, noise-std = 0.01 rng = np.random.default_rng(0) data = 3.0 * np.sin(theta + 0.1) + 0.5 + 0.01 * rng.standard_normal(npts) # first guesses for harmonic parameters guess_mean = np.mean(data) guess_std = 3 * np.std(data) / 2**0.5 guess_phase = 0 # Minimize the difference between the actual data and our "guessed" # parameters def optimize_func(x): return x[0] * np.sin(theta + x[1]) + x[2] - data est_std, est_phase, est_mean = leastsq( optimize_func, [guess_std, guess_phase, guess_mean])[0] # recreate the fitted curve using the optimized parameters data_fit = est_std * np.sin(theta + est_phase) + est_mean residual = data - data_fit assert_allclose(np.mean(residual), 0.0, atol=0.001) assert_allclose(np.std(residual), 0.01, atol=0.01) def test_harmonics_2(): # this uses the actual functional form used for fitting ellipses npts = 100 theta = np.linspace(0, 4 * np.pi, npts) y0_0 = 100.0 a1_0 = 10.0 b1_0 = 5.0 a2_0 = 8.0 b2_0 = 2.0 rng = np.random.default_rng(0) data = (y0_0 + a1_0 * np.sin(theta) + b1_0 * np.cos(theta) + a2_0 * np.sin(2 * theta) + b2_0 * np.cos(2 * theta) + 0.01 * rng.standard_normal(npts)) harmonics = fit_first_and_second_harmonics(theta, data) y0, a1, b1, a2, b2 = harmonics[0] data_fit = (y0 + a1 * np.sin(theta) + b1 * np.cos(theta) + a2 * np.sin(2 * theta) + b2 * np.cos(2 * theta) + 0.01 * rng.standard_normal(npts)) residual = data - data_fit assert_allclose(np.mean(residual), 0.0, atol=0.01) assert_allclose(np.std(residual), 0.015, atol=0.01) def test_harmonics_3(): """ Tests an upper harmonic fit. """ npts = 100 theta = np.linspace(0, 4 * np.pi, npts) y0_0 = 100.0 a1_0 = 10.0 b1_0 = 5.0 order = 3 rng = np.random.default_rng(0) data = (y0_0 + a1_0 * np.sin(order * theta) + b1_0 * np.cos(order * theta) + 0.01 * rng.standard_normal(npts)) harmonic = fit_upper_harmonic(theta, data, order) y0, a1, b1 = harmonic[0] rng = np.random.default_rng(0) data_fit = (y0 + a1 * np.sin(order * theta) + b1 * np.cos(order * theta) + 0.01 * rng.standard_normal(npts)) residual = data - data_fit assert_allclose(np.mean(residual), 0.0, atol=0.01) assert_allclose(np.std(residual), 0.015, atol=0.014) class TestFitEllipseSamples: def setup_class(self): # major axis parallel to X image axis self.data1 = make_test_image(seed=0) # major axis tilted 45 deg wrt X image axis self.data2 = make_test_image(pa=np.pi / 4, seed=0) def test_fit_ellipsesample_1(self): sample = EllipseSample(self.data1, 40.0) s = sample.extract() harmonics = fit_first_and_second_harmonics(s[0], s[2]) y0, a1, b1, a2, b2 = harmonics[0] assert_allclose(np.mean(y0), 200.019, atol=0.001) assert_allclose(np.mean(a1), -0.000138, atol=0.001) assert_allclose(np.mean(b1), 0.000254, atol=0.001) assert_allclose(np.mean(a2), -5.658e-05, atol=0.001) assert_allclose(np.mean(b2), -0.00911, atol=0.001) # check that harmonics subtract nicely model = first_and_second_harmonic_function( s[0], np.array([y0, a1, b1, a2, b2])) residual = s[2] - model assert_allclose(np.mean(residual), 0.0, atol=0.001) assert_allclose(np.std(residual), 0.015, atol=0.01) def test_fit_ellipsesample_2(self): # initial guess is rounder than actual image sample = EllipseSample(self.data1, 40.0, eps=0.1) s = sample.extract() harmonics = fit_first_and_second_harmonics(s[0], s[2]) y0, a1, b1, a2, b2 = harmonics[0] assert_allclose(np.mean(y0), 188.686, atol=0.001) assert_allclose(np.mean(a1), 0.000283, atol=0.001) assert_allclose(np.mean(b1), 0.00692, atol=0.001) assert_allclose(np.mean(a2), -0.000215, atol=0.001) assert_allclose(np.mean(b2), 10.153, atol=0.001) def test_fit_ellipsesample_3(self): # initial guess for center is offset sample = EllipseSample(self.data1, x0=220.0, y0=210.0, sma=40.0) s = sample.extract() harmonics = fit_first_and_second_harmonics(s[0], s[2]) y0, a1, b1, a2, b2 = harmonics[0] assert_allclose(np.mean(y0), 152.660, atol=0.001) assert_allclose(np.mean(a1), 55.338, atol=0.001) assert_allclose(np.mean(b1), 33.091, atol=0.001) assert_allclose(np.mean(a2), 33.036, atol=0.001) assert_allclose(np.mean(b2), -14.306, atol=0.001) def test_fit_ellipsesample_4(self): sample = EllipseSample(self.data2, 40.0, eps=0.4) s = sample.extract() harmonics = fit_first_and_second_harmonics(s[0], s[2]) y0, a1, b1, a2, b2 = harmonics[0] assert_allclose(np.mean(y0), 245.102, atol=0.001) assert_allclose(np.mean(a1), -0.003108, atol=0.001) assert_allclose(np.mean(b1), -0.0578, atol=0.001) assert_allclose(np.mean(a2), 28.781, atol=0.001) assert_allclose(np.mean(b2), -63.184, atol=0.001) def test_fit_upper_harmonics(self): data = make_test_image(noise=1.0e-10, seed=0) sample = EllipseSample(data, 40) fitter = EllipseFitter(sample) iso = fitter.fit(maxit=400) assert_allclose(iso.a3, 6.825e-7, atol=1.0e-8) assert_allclose(iso.b3, -1.68e-6, atol=1.0e-8) assert_allclose(iso.a4, 4.36e-6, atol=1.0e-8) assert_allclose(iso.b4, -4.73e-5, atol=1.0e-7) assert_allclose(iso.a3_err, 8.152e-6, atol=1.0e-7) assert_allclose(iso.b3_err, 8.115e-6, atol=1.0e-7) assert_allclose(iso.a4_err, 7.501e-6, atol=1.0e-7) assert_allclose(iso.b4_err, 7.473e-6, atol=1.0e-7) def test_upper_harmonics_sign(): """ Regression test for #1486/#1501. """ angle = 40.0 * np.pi / 180.0 g1 = Gaussian2D(100.0, 75, 75, 15, 3, theta=angle) g2 = Gaussian2D(100.0, 75, 75, 10, 8, theta=angle) ny = nx = 150 y, x = np.mgrid[0:ny, 0:nx] data = g1(x, y) + g2(x, y) geometry = EllipseGeometry(x0=75, y0=75, sma=20, eps=0.9, pa=angle) ellipse = Ellipse(data, geometry) isolist = ellipse.fit_image() # test image is "disky: disky isophotes have b4 > 0 # (boxy isophotes have b4 < 0) assert np.all(isolist.b4[30:] > 0) assert isolist.a3[-1] < 0 assert isolist.a4[-1] < 0 assert isolist.b3[-1] > 0 assert isolist.b4[-1] > 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/tests/test_integrator.py0000644000175100001660000001215314755160622024001 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the integrator module. """ import numpy as np import pytest from astropy.io import fits from numpy.testing import assert_allclose from photutils.datasets import get_path from photutils.isophote.integrator import (BILINEAR, MEAN, MEDIAN, NEAREST_NEIGHBOR) from photutils.isophote.sample import EllipseSample @pytest.mark.remote_data class TestData: def setup_class(self): path = get_path('isophote/synth_highsnr.fits', location='photutils-datasets', cache=True) hdu = fits.open(path) self.data = hdu[0].data hdu.close() def make_sample(self, masked=False, sma=40.0, integrmode=BILINEAR): if masked: data = np.ma.masked_values(self.data, 200.0, atol=10.0, rtol=0.0) else: data = self.data sample = EllipseSample(data, sma, integrmode=integrmode) s = sample.extract() assert len(s) == 3 assert len(s[0]) == len(s[1]) assert len(s[0]) == len(s[2]) return s, sample @pytest.mark.remote_data class TestUnmasked(TestData): def test_bilinear(self): s, sample = self.make_sample() assert len(s[0]) == 225 # intensities assert_allclose(np.mean(s[2]), 200.76, atol=0.01) assert_allclose(np.std(s[2]), 21.55, atol=0.01) # radii assert_allclose(np.max(s[1]), 40.0, atol=0.01) assert_allclose(np.min(s[1]), 32.0, atol=0.01) assert sample.total_points == 225 assert sample.actual_points == 225 def test_bilinear_small(self): # small radius forces sub-pixel sampling s, sample = self.make_sample(sma=10.0) # intensities assert_allclose(np.mean(s[2]), 1045.4, atol=0.1) assert_allclose(np.std(s[2]), 143.0, atol=0.1) # radii assert_allclose(np.max(s[1]), 10.0, atol=0.1) assert_allclose(np.min(s[1]), 8.0, atol=0.1) assert sample.total_points == 57 assert sample.actual_points == 57 def test_nearest_neighbor(self): s, sample = self.make_sample(integrmode=NEAREST_NEIGHBOR) assert len(s[0]) == 225 # intensities assert_allclose(np.mean(s[2]), 201.1, atol=0.1) assert_allclose(np.std(s[2]), 21.8, atol=0.1) # radii assert_allclose(np.max(s[1]), 40.0, atol=0.01) assert_allclose(np.min(s[1]), 32.0, atol=0.01) assert sample.total_points == 225 assert sample.actual_points == 225 def test_mean(self): s, sample = self.make_sample(integrmode=MEAN) assert len(s[0]) == 64 # intensities assert_allclose(np.mean(s[2]), 199.9, atol=0.1) assert_allclose(np.std(s[2]), 21.3, atol=0.1) # radii assert_allclose(np.max(s[1]), 40.0, atol=0.01) assert_allclose(np.min(s[1]), 32.0, atol=0.01) assert_allclose(sample.sector_area, 12.4, atol=0.1) assert sample.total_points == 64 assert sample.actual_points == 64 def test_mean_small(self): s, sample = self.make_sample(sma=5.0, integrmode=MEAN) assert len(s[0]) == 29 # intensities assert_allclose(np.mean(s[2]), 2339.0, atol=0.1) assert_allclose(np.std(s[2]), 284.7, atol=0.1) # radii assert_allclose(np.max(s[1]), 5.0, atol=0.01) assert_allclose(np.min(s[1]), 4.0, atol=0.01) assert_allclose(sample.sector_area, 2.0, atol=0.1) assert sample.total_points == 29 assert sample.actual_points == 29 def test_median(self): s, sample = self.make_sample(integrmode=MEDIAN) assert len(s[0]) == 64 # intensities assert_allclose(np.mean(s[2]), 199.9, atol=0.1) assert_allclose(np.std(s[2]), 21.3, atol=0.1) # radii assert_allclose(np.max(s[1]), 40.0, atol=0.01) assert_allclose(np.min(s[1]), 32.01, atol=0.01) assert_allclose(sample.sector_area, 12.4, atol=0.1) assert sample.total_points == 64 assert sample.actual_points == 64 @pytest.mark.remote_data class TestMasked(TestData): def test_bilinear(self): s, sample = self.make_sample(masked=True, integrmode=BILINEAR) assert len(s[0]) == 157 # intensities assert_allclose(np.mean(s[2]), 201.52, atol=0.01) assert_allclose(np.std(s[2]), 25.21, atol=0.01) # radii assert_allclose(np.max(s[1]), 40.0, atol=0.01) assert_allclose(np.min(s[1]), 32.0, atol=0.01) assert sample.total_points == 225 assert sample.actual_points == 157 def test_mean(self): s, sample = self.make_sample(masked=True, integrmode=MEAN) assert len(s[0]) == 51 # intensities assert_allclose(np.mean(s[2]), 199.9, atol=0.1) assert_allclose(np.std(s[2]), 24.12, atol=0.1) # radii assert_allclose(np.max(s[1]), 40.0, atol=0.01) assert_allclose(np.min(s[1]), 32.0, atol=0.01) assert_allclose(sample.sector_area, 12.4, atol=0.1) assert sample.total_points == 64 assert sample.actual_points == 51 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/tests/test_isophote.py0000644000175100001660000002576714755160622023474 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the isophote module. """ import numpy as np import pytest from astropy.io import fits from numpy.testing import assert_allclose from photutils.datasets import get_path from photutils.isophote.ellipse import Ellipse from photutils.isophote.fitter import EllipseFitter from photutils.isophote.geometry import EllipseGeometry from photutils.isophote.isophote import CentralPixel, Isophote, IsophoteList from photutils.isophote.sample import EllipseSample from photutils.isophote.tests.make_test_data import make_test_image DEFAULT_FIX = np.array([False, False, False, False]) @pytest.mark.remote_data class TestIsophote: def setup_class(self): path = get_path('isophote/M51.fits', location='photutils-datasets', cache=True) hdu = fits.open(path) self.data = hdu[0].data hdu.close() def test_fit(self): # low noise image, fitted perfectly by sample data = make_test_image(noise=1.0e-10, seed=0) sample = EllipseSample(data, 40) fitter = EllipseFitter(sample) iso = fitter.fit(maxit=400) assert iso.valid assert iso.stop_code in (0, 2) # fitted values assert iso.intens <= 201.0 assert iso.intens >= 199.0 assert iso.int_err <= 0.0010 assert iso.int_err >= 0.0009 assert iso.pix_stddev <= 0.03 assert iso.pix_stddev >= 0.02 assert abs(iso.grad) <= 4.25 assert abs(iso.grad) >= 4.20 # integrals assert iso.tflux_e <= 1.85e6 assert iso.tflux_e >= 1.82e6 assert iso.tflux_c <= 2.025e6 assert iso.tflux_c >= 2.022e6 # deviations from perfect ellipticity. Note # that sometimes a None covariance can be # generated by scipy.optimize.leastsq assert iso.a3 is None or abs(iso.a3) <= 0.01 assert iso.b3 is None or abs(iso.b3) <= 0.01 assert iso.a4 is None or abs(iso.a4) <= 0.01 assert iso.b4 is None or abs(iso.b4) <= 0.01 def test_m51(self): sample = EllipseSample(self.data, 21.44) fitter = EllipseFitter(sample) iso = fitter.fit() assert iso.valid assert iso.stop_code in (0, 2) # geometry g = iso.sample.geometry assert g.x0 >= (257 - 1.5) # position within 1.5 pixel assert g.x0 <= (257 + 1.5) assert g.y0 >= (259 - 1.5) assert g.y0 <= (259 + 2.0) assert g.eps >= (0.19 - 0.05) # eps within 0.05 assert g.eps <= (0.19 + 0.05) assert g.pa >= (0.62 - 0.05) # pa within 5 deg assert g.pa <= (0.62 + 0.05) # fitted values assert_allclose(iso.intens, 682.9, atol=0.1) assert_allclose(iso.rms, 83.27, atol=0.01) assert_allclose(iso.int_err, 7.63, atol=0.01) assert_allclose(iso.pix_stddev, 117.8, atol=0.1) assert_allclose(iso.grad, -36.08, atol=0.1) # integrals assert iso.tflux_e <= 1.20e6 assert iso.tflux_e >= 1.19e6 assert iso.tflux_c <= 1.38e6 assert iso.tflux_c >= 1.36e6 # deviations from perfect ellipticity. Note # that sometimes a None covariance can be # generated by scipy.optimize.leastsq assert iso.a3 is None or abs(iso.a3) <= 0.05 assert iso.b3 is None or abs(iso.b3) <= 0.05 assert iso.a4 is None or abs(iso.a4) <= 0.05 assert iso.b4 is None or abs(iso.b4) <= 0.05 def test_m51_niter(self): # compares with old STSDAS task. In this task, the # default for the starting value of SMA is 10; it # fits with 20 iterations. sample = EllipseSample(self.data, 10) fitter = EllipseFitter(sample) iso = fitter.fit() assert iso.valid assert iso.niter == 50 def test_isophote_comparisons(): data = make_test_image(seed=0) sma1 = 40.0 sma2 = 100.0 k = 5 sample0 = EllipseSample(data, sma1 + k) sample1 = EllipseSample(data, sma1 + k) sample2 = EllipseSample(data, sma2 + k) sample0.update(DEFAULT_FIX) sample1.update(DEFAULT_FIX) sample2.update(DEFAULT_FIX) iso0 = Isophote(sample0, k, 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(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.grad_error, np.ndarray) assert isinstance(result.grad_r_error, np.ndarray) assert isinstance(result.sarea, np.ndarray) assert isinstance(result.niter, np.ndarray) assert isinstance(result.ndata, np.ndarray) assert isinstance(result.nflag, np.ndarray) assert isinstance(result.valid, np.ndarray) assert isinstance(result.stop_code, np.ndarray) assert isinstance(result.tflux_c, np.ndarray) assert isinstance(result.tflux_e, np.ndarray) assert isinstance(result.npix_c, np.ndarray) assert isinstance(result.npix_e, np.ndarray) assert isinstance(result.a3, np.ndarray) assert isinstance(result.a4, np.ndarray) assert isinstance(result.b3, np.ndarray) assert isinstance(result.b4, np.ndarray) samples = result.sample assert isinstance(samples, list) assert isinstance(samples[0], EllipseSample) iso = result.get_closest(13.6) assert isinstance(iso, Isophote) assert_allclose(iso.sma, 14.0, atol=1e-6) def test_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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/tests/test_model.py0000644000175100001660000001152414755160622022724 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the model module. """ import warnings from contextlib import nullcontext import numpy as np import pytest from astropy.io import fits from astropy.modeling.models import Gaussian2D from astropy.utils.data import get_pkg_data_filename from photutils.datasets import get_path from photutils.isophote.ellipse import Ellipse from photutils.isophote.geometry import EllipseGeometry from photutils.isophote.isophote import IsophoteList from photutils.isophote.model import build_ellipse_model from photutils.isophote.tests.make_test_data import make_test_image from photutils.tests.helper import PYTEST_LT_80 @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, 10.0 / 180.0 * np.pi) ellipse = Ellipse(data, geometry=g, threshold=1.0e5) # NOTE: this sometimes emits warnings (e.g., py38, ubuntu), but # sometimes not. Here we simply ignore any RuntimeWarning, whether # there is one or not. with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) isophote_list = ellipse.fit_image() model = build_ellipse_model(data.shape, isophote_list, fill=np.mean(data[10:100, 10:100])) assert data.shape == model.shape residual = data - model assert np.mean(residual) <= 5.0 assert np.mean(residual) >= -5.0 def test_model_simulated_data(): data = make_test_image(nx=200, ny=200, i0=10.0, sma=5.0, eps=0.5, pa=np.pi / 3.0, noise=0.05, seed=0) g = EllipseGeometry(100.0, 100.0, 5.0, 0.5, np.pi / 3.0) ellipse = Ellipse(data, geometry=g, threshold=1.0e5) # Catch warnings that may arise from empty slices. This started # to happen on windows with scipy 1.15.0. with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) isophote_list = ellipse.fit_image() model = build_ellipse_model(data.shape, isophote_list, fill=np.mean(data[0:50, 0:50])) assert data.shape == model.shape residual = data - model assert np.mean(residual) <= 5.0 assert np.mean(residual) >= -5.0 def test_model_minimum_radius(): # This test requires a "defective" image that drives the # model building algorithm into a corner, where it fails. # With the algorithm fixed, it bypasses the failure and # succeeds in building the model image. filepath = get_pkg_data_filename('data/minimum_radius_test.fits') with fits.open(filepath) as hdu: data = hdu[0].data g = EllipseGeometry(50.0, 45, 530.0, 0.1, 10.0 / 180.0 * np.pi) g.find_center(data) ellipse = Ellipse(data, geometry=g) match1 = 'Degrees of freedom' ctx1 = pytest.warns(RuntimeWarning, match=match1) if PYTEST_LT_80: ctx2 = nullcontext() ctx3 = nullcontext() else: match2 = 'Mean of empty slice' match3 = 'invalid value encountered' ctx2 = pytest.warns(RuntimeWarning, match=match2) ctx3 = pytest.warns(RuntimeWarning, match=match3) with ctx1, ctx2, ctx3: isophote_list = ellipse.fit_image(sma0=40, minsma=0, maxsma=350.0, step=0.4, nclip=3) model = build_ellipse_model(data.shape, isophote_list, fill=np.mean(data[0:50, 0:50])) # It's enough that the algorithm reached this point. The # actual accuracy of the modelling is being tested elsewhere. assert data.shape == model.shape 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) 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/tests/test_regression.py0000644000175100001660000002063514755160622024007 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Despite being cast as a unit test, this code implements regression testing of the Ellipse algorithm, against results obtained by the stsdas$analysis/isophote task 'ellipse'. The stsdas task was run on test images and results were stored in tables. The code here runs the Ellipse algorithm on the same images, producing a list of Isophote instances. The contents of this list then get compared with the contents of the corresponding table. Some quantities are compared in assert statements. These were designed to be executed only when the synth_highsnr.fits image is used as input. That way, we are mainly checking numerical differences that originate in the algorithms themselves, and not caused by noise. The quantities compared this way are: * mean intensity: less than 1% diff. for sma > 3 pixels, 5% otherwise * ellipticity: less than 1% diff. for sma > 3 pixels, 20% otherwise * position angle: less than 1 deg. diff. for sma > 3 pixels, 20 deg. otherwise * X and Y position: less than 0.2 pixel diff. For the M51 image we have mostly good agreement with the SPP code in most of the parameters (mean isophotal intensity agrees within a fraction of 1% mostly), but every now and then the ellipticity and position angle of the semi-major axis may differ by a large amount from what the SPP code measures. The code also stops prematurely wrt the larger sma values measured by the SPP code. This is caused by a difference in the way the gradient relative error is measured in each case, and suggests that the SPP code may have a bug. The not-so-good behavior observed in the case of the M51 image is to be expected though. This image is exactly the type of galaxy image for which the algorithm *wasn't* designed for. It has an almost negligible smooth ellipsoidal component, and a lot of lumpy spiral structure that causes the radial gradient computation to go berserk. On top of that, the ellipticity is small (roundish isophotes) throughout the image, causing large relative errors and instability in the fitting algorithm. For now, we can only check the bilinear integration mode. The mean and median modes cannot be checked since the original 'ellipse' task has a bug that causes the creation of erroneous output tables. A partial comparison could be made if we write new code that reads the standard output of 'ellipse' instead, captured from screen, and use it as reference for the regression. """ import math import os.path as op import numpy as np import pytest from astropy.io import fits from astropy.table import Table from photutils.datasets import get_path from photutils.isophote.ellipse import Ellipse from photutils.isophote.integrator import BILINEAR # @pytest.mark.parametrize('name', ['M51', 'synth', 'synth_lowsnr', # 'synth_highsnr']) @pytest.mark.parametrize('name', ['synth_highsnr']) @pytest.mark.remote_data def test_regression(name, integrmode=BILINEAR, verbose=False): """ NOTE: The original code in SPP won't create the right table for the MEAN integration moder, so use the screen output at synth_table_mean.txt to compare results visually with synth_table_mean.fits. """ filename = f'{name}_table.fits' path = op.join(op.dirname(op.abspath(__file__)), 'data', filename) table = Table.read(path) nrows = len(table['SMA']) path = get_path(f'isophote/{name}.fits', location='photutils-datasets', cache=True) hdu = fits.open(path) data = hdu[0].data hdu.close() ellipse = Ellipse(data) isophote_list = ellipse.fit_image() ttype = [] tsma = [] tintens = [] tint_err = [] tpix_stddev = [] trms = [] tellip = [] tpa = [] tx0 = [] ty0 = [] trerr = [] tndata = [] tnflag = [] tniter = [] tstop = [] for row in range(nrows): try: iso = isophote_list[row] except IndexError: # skip non-existent rows in isophote list, if that's the case. break # data from Isophote sma_i = iso.sample.geometry.sma intens_i = iso.intens int_err_i = iso.int_err if iso.int_err else 0.0 pix_stddev_i = iso.pix_stddev if iso.pix_stddev else 0.0 rms_i = iso.rms if iso.rms else 0.0 ellip_i = iso.sample.geometry.eps if iso.sample.geometry.eps else 0.0 pa_i = iso.sample.geometry.pa if iso.sample.geometry.pa else 0.0 x0_i = iso.sample.geometry.x0 y0_i = iso.sample.geometry.y0 rerr_i = (iso.sample.gradient_relative_error if iso.sample.gradient_relative_error else 0.0) ndata_i = iso.ndata nflag_i = iso.nflag niter_i = iso.niter stop_i = iso.stop_code # convert to old code reference system pa_i = (pa_i - np.pi / 2) / np.pi * 180.0 x0_i += 1 y0_i += 1 # ref data from table sma_t = table['SMA'][row] intens_t = table['INTENS'][row] int_err_t = table['INT_ERR'][row] pix_stddev_t = table['PIX_VAR'][row] rms_t = table['RMS'][row] ellip_t = table['ELLIP'][row] pa_t = table['PA'][row] x0_t = table['X0'][row] y0_t = table['Y0'][row] rerr_t = table['GRAD_R_ERR'][row] ndata_t = table['NDATA'][row] nflag_t = table['NFLAG'][row] niter_t = table['NITER'][row] if table['NITER'][row] else 0 stop_t = table['STOP'][row] if table['STOP'][row] else -1 # relative differences sma_d = (sma_i - sma_t) / sma_t * 100.0 if sma_t > 0.0 else 0.0 intens_d = (intens_i - intens_t) / intens_t * 100.0 int_err_d = ((int_err_i - int_err_t) / int_err_t * 100.0 if int_err_t > 0.0 else 0.0) pix_stddev_d = ((pix_stddev_i - pix_stddev_t) / pix_stddev_t * 100.0 if pix_stddev_t > 0.0 else 0.0) rms_d = (rms_i - rms_t) / rms_t * 100.0 if rms_t > 0.0 else 0.0 ellip_d = (ellip_i - ellip_t) / ellip_t * 100.0 pa_d = pa_i - pa_t # diff in angle is absolute x0_d = x0_i - x0_t # diff in position is absolute y0_d = y0_i - y0_t rerr_d = rerr_i - rerr_t # diff in relative error is absolute ndata_d = (ndata_i - ndata_t) / ndata_t * 100.0 nflag_d = 0 niter_d = 0 stop_d = 0 if stop_i == stop_t else -1 if verbose: 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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/isophote/tests/test_sample.py0000644000175100001660000000350414755160622023104 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the sample module. """ import numpy as np import pytest from photutils.isophote.integrator import (BILINEAR, MEAN, MEDIAN, NEAREST_NEIGHBOR) from photutils.isophote.isophote import Isophote from photutils.isophote.sample import EllipseSample from photutils.isophote.tests.make_test_data import make_test_image DEFAULT_FIX = np.array([False, False, False, False]) DATA = make_test_image(background=100.0, i0=0.0, noise=10.0, seed=0) # the median is not so good at estimating rms @pytest.mark.parametrize(('integrmode', 'amin', 'amax'), [(NEAREST_NEIGHBOR, 7.0, 15.0), (BILINEAR, 7.0, 15.0), (MEAN, 7.0, 15.0), (MEDIAN, 6.0, 15.0)]) def test_scatter(integrmode, amin, amax): """ Check that the pixel standard deviation can be reliably estimated from the rms scatter and the sector area. The test data is just a flat image with noise, no galaxy. We define the noise rms and then compare how close the pixel std dev estimated at extraction matches this input noise. """ sample = EllipseSample(DATA, 50.0, astep=0.2, integrmode=integrmode) sample.update(DEFAULT_FIX) iso = Isophote(sample, 0, 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(DEFAULT_FIX) x, y = sample.coordinates() assert isinstance(x, np.ndarray) assert isinstance(y, np.ndarray) def test_sclip(): sample = EllipseSample(DATA, 50.0, nclip=3) sample.update(DEFAULT_FIX) x, y = sample.coordinates() assert isinstance(x, np.ndarray) assert isinstance(y, np.ndarray) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1739907483.708927 photutils-2.2.0/photutils/morphology/0000755000175100001660000000000014755160634017416 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/morphology/__init__.py0000644000175100001660000000041614755160622021525 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools for measuring morphological properties of objects in an astronomical image. """ from .core import * # noqa: F401, F403 from .non_parametric import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/morphology/core.py0000644000175100001660000000432714755160622020723 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for measuring morphological properties of sources. """ import numpy as np __all__ = ['data_properties'] def data_properties(data, mask=None, background=None, wcs=None): """ Calculate the morphological properties (and centroid) of a 2D array (e.g., an image cutout of an object) using image moments. Parameters ---------- data : array_like or `~astropy.units.Quantity` The 2D array of the image. mask : array_like (bool), optional A boolean mask, with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. background : float, array_like, or `~astropy.units.Quantity`, optional The background level that was previously present in the input ``data``. ``background`` may either be a scalar value or a 2D image with the same shape as the input ``data``. Inputting the ``background`` merely allows for its properties to be measured within each source segment. The input ``background`` does *not* get subtracted from the input ``data``, which should already be background-subtracted. wcs : WCS object or `None`, optional A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). If `None`, then all sky-based properties will be set to `None`. Returns ------- result : `~photutils.segmentation.SourceCatalog` instance A `~photutils.segmentation.SourceCatalog` object. """ # prevent circular import from photutils.segmentation import SegmentationImage, SourceCatalog segment_image = SegmentationImage(np.ones(data.shape, dtype=int)) if background is not None: background = np.atleast_1d(background) if background.shape == (1,): background = np.zeros(data.shape) + background return SourceCatalog(data, segment_image, mask=mask, background=background, wcs=wcs)[0] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/morphology/non_parametric.py0000644000175100001660000000440314755160622022767 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides functions for measuring non-parametric morphologies of sources. """ import numpy as np __all__ = ['gini'] def gini(data, mask=None): r""" Calculate the `Gini coefficient `_ of a 2D array. The Gini coefficient is calculated using the prescription from `Lotz et al. 2004 `_ as: .. math:: G = \frac{1}{\left | \bar{x} \right | n (n - 1)} \sum^{n}_{i} (2i - n - 1) \left | x_i \right | where :math:`\bar{x}` is the mean over all pixel values :math:`x_i`. If the sum of all pixel values is zero, the Gini coefficient is zero. The Gini coefficient is a way of measuring the inequality in a given set of values. In the context of galaxy morphology, it measures how the light of a galaxy image is distributed among its pixels. A Gini coefficient value of 0 corresponds to a galaxy image with the light evenly distributed over all pixels while a Gini coefficient value of 1 represents a galaxy image with all its light concentrated in just one pixel. Usually Gini's measurement needs some sort of preprocessing for defining the galaxy region in the image based on the quality of the input data. As there is not a general standard for doing this, this is left for the user. Parameters ---------- data : array_like The 2D data array or object that can be converted to an array. mask : array_like, optional A boolean mask with the same shape as ``data`` where `True` values indicate masked pixels. Masked pixels are excluded from the calculation. Returns ------- result : float The Gini coefficient of the input 2D array. """ values = data[~mask] if mask is not None else np.ravel(data) if np.all(np.isnan(values)): return np.nan npix = np.size(values) normalization = np.abs(np.mean(values)) * npix * (npix - 1) if normalization == 0: return 0.0 kernel = ((2.0 * np.arange(1, npix + 1) - npix - 1) * np.abs(np.sort(values))) return np.sum(kernel) / normalization ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1739907483.708927 photutils-2.2.0/photutils/morphology/tests/0000755000175100001660000000000014755160634020560 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/morphology/tests/__init__.py0000644000175100001660000000000014755160622022654 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/morphology/tests/test_core.py0000644000175100001660000000322614755160622023121 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the core module. """ import numpy as np import pytest from numpy.testing import assert_allclose from photutils.datasets import make_wcs from photutils.morphology import data_properties from photutils.utils._optional_deps import HAS_SKIMAGE XCS = [25.7] YCS = [26.2] XSTDDEVS = [3.2, 4.0] YSTDDEVS = [5.7, 4.1] THETAS = np.array([30.0, 45.0]) * np.pi / 180.0 DATA = np.zeros((3, 3)) DATA[0:2, 1] = 1.0 DATA[1, 0:2] = 1.0 DATA[1, 1] = 2.0 @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_data_properties(): data = np.ones((2, 2)).astype(float) mask = np.array([[False, False], [True, True]]) props = data_properties(data, mask=None) props2 = data_properties(data, mask=mask) properties = ['xcentroid', 'ycentroid'] result = [getattr(props, i) for i in properties] result2 = [getattr(props2, i) for i in properties] assert_allclose([0.5, 0.5], result, rtol=0, atol=1.0e-6) assert_allclose([0.5, 0.0], result2, rtol=0, atol=1.0e-6) assert props.area.value == 4.0 assert props2.area.value == 2.0 wcs = make_wcs(data.shape) props = data_properties(data, mask=None, wcs=wcs) assert props.sky_centroid is not None @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_data_properties_bkg(): data = np.ones((3, 3)).astype(float) props = data_properties(data, background=1.0) assert props.area.value == 9.0 assert props.background_sum == 9.0 match = 'background must be a 2D array' with pytest.raises(ValueError, match=match): data_properties(data, background=[1.0, 2.0]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/morphology/tests/test_non_parametric.py0000644000175100001660000000167714755160622025202 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the non_parametric module. """ import numpy as np from photutils.morphology.non_parametric import gini def test_gini(): """ Test Gini coefficient calculation. """ data_evenly_distributed = np.ones((100, 100)) data_point_like = np.zeros((100, 100)) data_point_like[50, 50] = 1 assert gini(data_evenly_distributed) == 0.0 assert gini(data_point_like) == 1.0 def test_gini_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_normalization(): data = np.zeros((100, 100)) assert gini(data) == 0.0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.7099268 photutils-2.2.0/photutils/profiles/0000755000175100001660000000000014755160634017042 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/profiles/__init__.py0000644000175100001660000000042514755160622021151 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools for generating radial profiles. """ from .core import * # noqa: F401, F403 from .curve_of_growth import * # noqa: F401, F403 from .radial_profile import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/profiles/core.py0000644000175100001660000002717214755160622020352 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides a base class for profiles. """ import abc import warnings import numpy as np from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from photutils.utils._quantity_helpers import process_quantities from photutils.utils._stats import nanmax, nansum __all__ = ['ProfileBase'] class ProfileBase(metaclass=abc.ABCMeta): """ Abstract base class for profile classes. Parameters ---------- data : 2D `~numpy.ndarray` The 2D data array. The data should be background-subtracted. xycen : tuple of 2 floats The ``(x, y)`` pixel coordinate of the source center. radii : 1D float `~numpy.ndarray` An array of radii defining the radial bins. ``radii`` must be strictly increasing with a minimum value greater than or equal to zero, and contain at least 2 values. The radial spacing does not need to be constant. For `~photutils.profiles.RadialProfile`, the input radii are the *edges* of the radial annulus bins, and the output `radius` represents the bin centers. error : 2D `~numpy.ndarray`, optional The 1-sigma errors of the input ``data``. ``error`` is assumed to include all sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. mask : 2D bool `~numpy.ndarray`, optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. """ def __init__(self, data, xycen, radii, *, error=None, mask=None, method='exact', subpixels=5): (data, error), unit = process_quantities((data, error), ('data', 'error')) if error is not None and error.shape != data.shape: raise ValueError('error must have the same shape as data') self.data = data self.unit = unit self.xycen = xycen self.radii = self._validate_radii(radii) self.error = error self.mask = self._compute_mask(data, error, mask) self.method = method self.subpixels = subpixels self.normalization_value = 1.0 def _validate_radii(self, radii): radii = np.array(radii) if radii.ndim != 1 or radii.size < 2: raise ValueError('radii must be a 1D array and have at ' 'least two values') if radii.min() < 0: raise ValueError('minimum radii must be >= 0') if not np.all(radii[1:] > radii[:-1]): raise ValueError('radii must be strictly increasing') return radii def _compute_mask(self, data, error, mask): """ Compute the mask array, automatically masking non-finite data or error values. """ badmask = ~np.isfinite(data) if error is not None: badmask |= ~np.isfinite(error) if mask is not None: if mask.shape != data.shape: raise ValueError('mask must have the same shape as data') badmask &= ~mask # non-finite values not in input mask mask |= badmask # all masked pixels else: mask = badmask if np.any(badmask): warnings.warn('Input data contains non-finite values (e.g., NaN ' 'or inf) that were automatically masked.', AstropyUserWarning) return mask @lazyproperty def radius(self): """ The profile radius in pixels as a 1D `~numpy.ndarray`. """ raise NotImplementedError('Needs to be implemented in a subclass.') @property @abc.abstractmethod def profile(self): """ The radial profile as a 1D `~numpy.ndarray`. """ raise NotImplementedError('Needs to be implemented in a subclass.') @property @abc.abstractmethod def profile_error(self): """ The radial profile errors as a 1D `~numpy.ndarray`. """ raise NotImplementedError('Needs to be implemented in a subclass.') @lazyproperty def _circular_apertures(self): """ A list of `~photutils.aperture.CircularAperture` objects. The first element may be `None`. """ from photutils.aperture import CircularAperture apertures = [] for radius in self.radii: if radius <= 0.0: apertures.append(None) else: apertures.append(CircularAperture(self.xycen, radius)) return apertures @lazyproperty def _photometry(self): """ The aperture fluxes, flux errors, and areas as a function of radius. """ fluxes = [] fluxerrs = [] areas = [] for aperture in self._circular_apertures: if aperture is None: flux, fluxerr = [0.0], [0.0] area = 0.0 else: flux, fluxerr = aperture.do_photometry( self.data, error=self.error, mask=self.mask, method=self.method, subpixels=self.subpixels) area = aperture.area_overlap(self.data, mask=self.mask, method=self.method, subpixels=self.subpixels) fluxes.append(flux[0]) if self.error is not None: fluxerrs.append(fluxerr[0]) areas.append(area) fluxes = np.array(fluxes) fluxerrs = np.array(fluxerrs) areas = np.array(areas) if self.unit is not None: fluxes <<= self.unit fluxerrs <<= self.unit return fluxes, fluxerrs, areas def normalize(self, method='max'): """ Normalize the profile. Parameters ---------- method : {'max', 'sum'}, optional The method used to normalize the profile: * ``'max'`` (default): The profile is normalized such that its maximum value is 1. * ``'sum'``: The profile is normalized such that its sum (integral) is 1. """ if method == 'max': normalization = nanmax(self.profile) elif method == 'sum': normalization = nansum(self.profile) else: raise ValueError('invalid method, must be "max" or "sum"') # NOTE: max and sum will never be NaN (automatically masked) if normalization == 0: warnings.warn('The profile cannot be normalized because the ' 'max or sum is zero.', AstropyUserWarning) else: # normalization_values accumulate if normalize is run # multiple times (e.g., different methods) self.normalization_value *= normalization # need to use __dict__ as these are lazy properties self.__dict__['profile'] = self.profile / normalization self.__dict__['profile_error'] = self.profile_error / normalization if 'data_profile' in self.__dict__: self.__dict__['data_profile'] = (self.data_profile / normalization) def unnormalize(self): """ Unnormalize the profile back to the original state before any calls to `normalize`. """ self.__dict__['profile'] = self.profile * self.normalization_value self.__dict__['profile_error'] = (self.profile_error * self.normalization_value) if 'data_profile' in self.__dict__: self.__dict__['data_profile'] = (self.data_profile * self.normalization_value) self.normalization_value = 1.0 def plot(self, ax=None, **kwargs): """ Plot the profile. Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. **kwargs : dict, optional Any keyword arguments accepted by `matplotlib.pyplot.plot`. Returns ------- lines : list of `~matplotlib.lines.Line2D` A list of lines representing the plotted data. """ import matplotlib.pyplot as plt if ax is None: ax = plt.gca() lines = ax.plot(self.radius, self.profile, **kwargs) ax.set_xlabel('Radius (pixels)') ylabel = 'Profile' if self.unit is not None: ylabel = f'{ylabel} ({self.unit})' ax.set_ylabel(ylabel) return lines def plot_error(self, ax=None, **kwargs): """ Plot the profile errors. Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. **kwargs : dict, optional Any keyword arguments accepted by `matplotlib.pyplot.fill_between`. Returns ------- lines : `matplotlib.collections.PolyCollection` A `~matplotlib.collections.PolyCollection` containing the plotted polygons. """ if self.profile_error.shape == (0,): warnings.warn('Errors were not input', AstropyUserWarning) return None import matplotlib.pyplot as plt if ax is None: ax = plt.gca() # set default fill_between facecolor # facecolor must be first key, otherwise it will override color kwarg # (i.e., cannot use setdefault here) if 'facecolor' not in kwargs: kws = {'facecolor': (0.5, 0.5, 0.5, 0.3)} kws.update(kwargs) else: kws = kwargs profile = self.profile profile_error = self.profile_error if self.unit is not None: profile = profile.value profile_error = profile_error.value ymin = profile - profile_error ymax = profile + profile_error return ax.fill_between(self.radius, ymin, ymax, **kws) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/profiles/curve_of_growth.py0000644000175100001660000003115414755160622022617 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for generating curves of growth. """ import numpy as np from astropy.utils import lazyproperty from scipy.interpolate import PchipInterpolator from photutils.profiles.core import ProfileBase __all__ = ['CurveOfGrowth'] class CurveOfGrowth(ProfileBase): """ Class to create a curve of growth using concentric circular apertures. The curve of growth profile represents the circular aperture flux as a function of circular radius. Parameters ---------- data : 2D `~numpy.ndarray` The 2D data array. The data should be background-subtracted. Non-finite values (e.g., NaN or inf) in the ``data`` or ``error`` array are automatically masked. xycen : tuple of 2 floats The ``(x, y)`` pixel coordinate of the source center. radii : 1D float `~numpy.ndarray` An array of the circular radii. ``radii`` must be strictly increasing with a minimum value greater than zero, and contain at least 2 values. The radial spacing does not need to be constant. error : 2D `~numpy.ndarray`, optional The 1-sigma errors of the input ``data``. ``error`` is assumed to include all sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. Non-finite values (e.g., NaN or inf) in the ``data`` or ``error`` array are automatically masked. mask : 2D bool `~numpy.ndarray`, optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. Examples -------- >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from astropy.visualization import simple_norm >>> from photutils.centroids import centroid_quadratic >>> from photutils.datasets import make_noise_image >>> from photutils.profiles import CurveOfGrowth Create an artificial single source. Note that this image does not have any background. >>> gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) >>> yy, xx = np.mgrid[0:100, 0:100] >>> data = gmodel(xx, yy) >>> bkg_sig = 2.4 >>> noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) >>> data += noise >>> error = np.zeros_like(data) + bkg_sig Create the curve of growth. >>> xycen = centroid_quadratic(data, xpeak=48, ypeak=52) >>> radii = np.arange(1, 26) >>> cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) >>> print(cog.radius) # doctest: +FLOAT_CMP [ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25] >>> print(cog.profile) # doctest: +FLOAT_CMP [ 130.57472018 501.34744442 1066.59182074 1760.50163608 2502.13955554 3218.50667597 3892.81448231 4455.36403436 4869.66609313 5201.99745378 5429.02043984 5567.28370644 5659.24831854 5695.06577065 5783.46217755 5824.01080702 5825.59003768 5818.22316662 5866.52307412 5896.96917375 5948.92254787 5968.30540534 5931.15611704 5941.94457249 5942.06535486] >>> print(cog.profile_error) # doctest: +FLOAT_CMP [ 4.25388924 8.50777848 12.76166773 17.01555697 21.26944621 25.52333545 29.7772247 34.03111394 38.28500318 42.53889242 46.79278166 51.04667091 55.30056015 59.55444939 63.80833863 68.06222787 72.31611712 76.57000636 80.8238956 85.07778484 89.33167409 93.58556333 97.83945257 102.09334181 106.34723105] 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_quadratic from photutils.datasets import make_noise_image from photutils.profiles import CurveOfGrowth # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.4 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) data += noise error = np.zeros_like(data) + bkg_sig # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the curve of growth radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) # plot the curve of growth 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_quadratic from photutils.datasets import make_noise_image from photutils.profiles import CurveOfGrowth # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.4 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) data += noise error = np.zeros_like(data) + bkg_sig # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the curve of growth radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) # plot the curve of growth cog.normalize() fig, ax = plt.subplots(figsize=(8, 6)) cog.plot(ax=ax) cog.plot_error(ax=ax) Plot a couple of the apertures on the data. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from astropy.visualization import simple_norm from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import CurveOfGrowth # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.4 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) data += noise error = np.zeros_like(data) + bkg_sig # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the curve of growth radii = np.arange(1, 26) cog = CurveOfGrowth(data, xycen, radii, error=error, mask=None) norm = simple_norm(data, 'sqrt') 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) """ def __init__(self, data, xycen, radii, *, error=None, mask=None, method='exact', subpixels=5): radii = np.array(radii) if radii.min() <= 0: raise ValueError('radii must be > 0') super().__init__(data, xycen, radii, error=error, mask=mask, method=method, subpixels=subpixels) @lazyproperty def radius(self): """ The profile radius in pixels as a 1D `~numpy.ndarray`. This is the same as the input ``radii``. Note that these are the radii of the circular apertures used to measure the profile. Thus, they are the radial values that enclose the given flux. They can be used directly to measure the encircled energy/flux at a given radius. """ return self.radii @lazyproperty def apertures(self): """ A list of `~photutils.aperture.CircularAperture` objects used to measure the profile. If ``radius_min`` is zero, then the first item will be `None`. """ return self._circular_apertures @lazyproperty def profile(self): """ The curve-of-growth profile as a 1D `~numpy.ndarray`. """ return self._photometry[0] @lazyproperty def profile_error(self): """ The curve-of-growth profile errors as a 1D `~numpy.ndarray`. """ return self._photometry[1] @lazyproperty def area(self): """ The unmasked area in each circular aperture as a function of radius as a 1D `~numpy.ndarray`. """ return self._photometry[2] def calc_ee_at_radius(self, radius): """ Calculate the encircled energy at a given radius using a cubic interpolator based on the profile data. Note that this function assumes that input data has been normalized such that the total enclosed flux is 1 for an infinitely large radius. You can also use the `normalize` method before calling this method to normalize the profile to be 1 at the largest input ``radii``. Parameters ---------- radius : float or 1D `~numpy.ndarray` The circular radius/radii. Returns ------- ee : `~numpy.ndarray` The encircled energy at each radius/radii. Returns NaN for radii outside the range of the profile data. """ return PchipInterpolator(self.radius, self.profile, extrapolate=False)(radius) def calc_radius_at_ee(self, ee): """ Calculate the radius at a given encircled energy using a cubic interpolator based on the profile data. Note that this function assumes that input data has been normalized such that the total enclosed flux is 1 for an infinitely large radius. You can also use the `normalize` method before calling this method to normalize the profile to be 1 at the largest input ``radii``. This interpolator returns values only for regions where the curve-of-growth profile is monotonically increasing. Parameters ---------- ee : float or 1D `~numpy.ndarray` The encircled energy. Returns ------- radius : `~numpy.ndarray` The radius at each encircled energy. Returns NaN for encircled energies outside the range of the profile data. """ # restrict the profile to the monotonically increasing region; # this is necessary for the interpolator radius = self.radius profile = self.profile diff = np.diff(profile) <= 0 if np.any(diff): idx = np.argmax(diff) # first non-monotonic point radius = radius[0:idx] profile = profile[0:idx] if len(radius) < 2: raise ValueError('The curve-of-growth profile is not ' 'monotonically increasing even at the ' 'smallest radii -- cannot interpolate. Try ' 'using different input radii (especially the ' 'starting radii) and/or using the "exact" ' 'aperture overlap method.') return PchipInterpolator(profile, radius, extrapolate=False)(ee) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/profiles/radial_profile.py0000644000175100001660000004171514755160622022375 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for generating radial profiles. """ import warnings import numpy as np from astropy.modeling.fitting import TRFLSQFitter from astropy.modeling.models import Gaussian1D from astropy.stats import gaussian_sigma_to_fwhm from astropy.utils import lazyproperty from photutils.profiles.core import ProfileBase __all__ = ['RadialProfile'] class RadialProfile(ProfileBase): """ Class to create a radial profile using concentric circular annulus apertures. The radial profile represents the azimuthally-averaged flux in circular annuli apertures as a function of radius. For this class, the input radii represent the edges of the radial bins. This differs from the `RadialProfile` class, where the inputs represent the centers of the radial bins. The output `radius` attribute represents the bin centers. Parameters ---------- data : 2D `~numpy.ndarray` The 2D data array. The data should be background-subtracted. Non-finite values (e.g., NaN or inf) in the ``data`` or ``error`` array are automatically masked. xycen : tuple of 2 floats The ``(x, y)`` pixel coordinate of the source center. radii : 1D float `~numpy.ndarray` An array of radii defining the *edges* of the radial bins. ``radii`` must be strictly increasing with a minimum value greater than or equal to zero, and contain at least 2 values. The radial spacing does not need to be constant. The output `radius` attribute will be defined at the bin centers. error : 2D `~numpy.ndarray`, optional The 1-sigma errors of the input ``data``. ``error`` is assumed to include all sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. Non-finite values (e.g., NaN or inf) in the ``data`` or ``error`` array are automatically masked. mask : 2D bool `~numpy.ndarray`, optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. method : {'exact', 'center', 'subpixel'}, optional The method used to determine the overlap of the aperture on the pixel grid: * ``'exact'`` (default): The exact fractional overlap of the aperture and each pixel is calculated. The aperture weights will contain values between 0 and 1. * ``'center'``: A pixel is considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. The aperture weights will contain values only of 0 (out) and 1 (in). * ``'subpixel'``: A pixel is divided into subpixels (see the ``subpixels`` keyword), each of which are considered to be entirely in or out of the aperture depending on whether its center is in or out of the aperture. If ``subpixels=1``, this method is equivalent to ``'center'``. The aperture weights will contain values between 0 and 1. subpixels : int, optional For the ``'subpixel'`` method, resample pixels by this factor in each dimension. That is, each pixel is divided into ``subpixels**2`` subpixels. This keyword is ignored unless ``method='subpixel'``. See Also -------- RadialProfile Notes ----- If the minimum of ``radii`` is zero, then a circular aperture with radius equal to ``radii[1]`` will be used for the innermost aperture. Examples -------- >>> import numpy as np >>> from astropy.modeling.models import Gaussian2D >>> from photutils.centroids import centroid_quadratic >>> from photutils.datasets import make_noise_image >>> from photutils.profiles import RadialProfile Create an artificial single source. Note that this image does not have any background. >>> gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) >>> yy, xx = np.mgrid[0:100, 0:100] >>> data = gmodel(xx, yy) >>> bkg_sig = 2.4 >>> noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) >>> data += noise >>> error = np.zeros_like(data) + bkg_sig Create the radial profile. >>> xycen = centroid_quadratic(data, xpeak=48, ypeak=52) >>> edge_radii = np.arange(26) >>> rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) >>> print(rp.radius) # doctest: +FLOAT_CMP [ 0.5 1.5 2.5 3.5 4.5 5.5 6.5 7.5 8.5 9.5 10.5 11.5 12.5 13.5 14.5 15.5 16.5 17.5 18.5 19.5 20.5 21.5 22.5 23.5 24.5] >>> print(rp.profile) # doctest: +FLOAT_CMP [ 4.15632243e+01 3.93402079e+01 3.59845746e+01 3.15540506e+01 2.62300757e+01 2.07297033e+01 1.65106801e+01 1.19376723e+01 7.75743772e+00 5.56759777e+00 3.44112671e+00 1.91350281e+00 1.17092981e+00 4.22261078e-01 9.70256904e-01 4.16355795e-01 1.52328707e-02 -6.69985111e-02 4.15522650e-01 2.48494731e-01 4.03348112e-01 1.43482678e-01 -2.62777461e-01 7.30653622e-02 7.84616804e-04] >>> print(rp.profile_error) # doctest: +FLOAT_CMP [1.354055 0.78176402 0.60555181 0.51178468 0.45135167 0.40826294 0.37554729 0.3496155 0.32840658 0.31064152 0.29547903 0.28233999 0.270811 0.26058801 0.2514417 0.24319546 0.23571072 0.22887707 0.22260527 0.21682233 0.21146786 0.20649145 0.2018506 0.19750922 0.19343643] Plot the radial profile. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.4 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) data += noise error = np.zeros_like(data) + bkg_sig # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) # plot the radial profile fig, ax = plt.subplots(figsize=(8, 6)) rp.plot(ax=ax) rp.plot_error(ax=ax) 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_quadratic from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.4 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) data += noise error = np.zeros_like(data) + bkg_sig # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) # plot the radial profile 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_quadratic from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.4 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) data += noise error = np.zeros_like(data) + bkg_sig # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) # plot the radial profile rp.normalize() 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_quadratic from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.4 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) data += noise error = np.zeros_like(data) + bkg_sig # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) norm = simple_norm(data, 'sqrt') 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 a 1D Gaussian to the radial profile and return the Gaussian model. >>> rp.gaussian_fit # doctest: +FLOAT_CMP >>> print(rp.gaussian_fwhm) # doctest: +FLOAT_CMP 11.09260130738712 Plot the fitted 1D Gaussian on the radial profile. .. plot:: import matplotlib.pyplot as plt import numpy as np from astropy.modeling.models import Gaussian2D from photutils.centroids import centroid_quadratic from photutils.datasets import make_noise_image from photutils.profiles import RadialProfile # create an artificial single source gmodel = Gaussian2D(42.1, 47.8, 52.4, 4.7, 4.7, 0) yy, xx = np.mgrid[0:100, 0:100] data = gmodel(xx, yy) bkg_sig = 2.4 noise = make_noise_image(data.shape, mean=0., stddev=bkg_sig, seed=123) data += noise error = np.zeros_like(data) + bkg_sig # find the source centroid xycen = centroid_quadratic(data, xpeak=48, ypeak=52) # create the radial profile edge_radii = np.arange(26) rp = RadialProfile(data, xycen, edge_radii, error=error, mask=None) # plot the radial profile rp.normalize() 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.legend() """ @lazyproperty def radius(self): """ The profile radius (bin centers) in pixels as a 1D `~numpy.ndarray`. The returned radius values are defined as the arithmetic means of the input radial-bins edges (``radii``). For logarithmically-spaced input ``radii``, one could instead use a radius array defined using the geometric mean of the bin edges, i.e. ``np.sqrt(radii[:-1] * radii[1:])``. """ # define the radial bin centers from the radial bin edges return (self.radii[:-1] + self.radii[1:]) / 2 @lazyproperty def apertures(self): """ A list of the circular annulus apertures used to measure the radial profile. If the ``min_radius`` is less than or equal to half the ``radius_step``, then a circular aperture with radius equal to ``min_radius + 0.5 * radius_step`` will be used for the innermost aperture. """ from photutils.aperture import CircularAnnulus, CircularAperture apertures = [] for i in range(len(self.radii) - 1): try: aperture = CircularAnnulus(self.xycen, self.radii[i], self.radii[i + 1]) except ValueError: # zero radius aperture = CircularAperture(self.xycen, self.radii[i + 1]) apertures.append(aperture) return apertures @lazyproperty def _flux(self): """ The flux in a circular annulus. """ return np.diff(self._photometry[0]) @lazyproperty def _fluxerr(self): """ The flux error in a circular annulus. """ return np.sqrt(np.diff(self._photometry[1] ** 2)) @lazyproperty def area(self): """ The unmasked area in each circular annulus (or aperture) as a function of radius as a 1D `~numpy.ndarray`. """ return np.diff(self._photometry[2]) @lazyproperty def profile(self): """ The radial profile as a 1D `~numpy.ndarray`. """ # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return self._flux / self.area @lazyproperty def profile_error(self): """ The radial profile errors as a 1D `~numpy.ndarray`. """ if self.error is None: return self._fluxerr # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) return self._fluxerr / self.area @lazyproperty def _profile_nanmask(self): return np.isfinite(self.profile) @lazyproperty def gaussian_fit(self): """ The fitted 1D Gaussian to the radial profile as a `~astropy.modeling.functional_models.Gaussian1D` model. The Gaussian fit will not change if the profile normalization is changed after performing the fit. """ profile = self.profile[self._profile_nanmask] radius = self.radius[self._profile_nanmask] amplitude = np.max(profile) std = np.sqrt(abs(np.sum(profile * radius**2) / np.sum(profile))) g_init = Gaussian1D(amplitude=amplitude, mean=0.0, stddev=std) g_init.mean.fixed = True fitter = TRFLSQFitter() return fitter(g_init, radius, profile) @lazyproperty def gaussian_profile(self): """ The fitted 1D Gaussian profile to the radial profile as a 1D `~numpy.ndarray`. The Gaussian profile will not change if the profile normalization is changed after performing the fit. """ return self.gaussian_fit(self.radius) @lazyproperty def gaussian_fwhm(self): """ The full-width at half-maximum (FWHM) in pixels of the 1D Gaussian fitted to the radial profile. """ return self.gaussian_fit.stddev.value * gaussian_sigma_to_fwhm @lazyproperty def _data_profile(self): """ The raw data profile returned as a 1D arrays (`~numpy.ndarray`) of radii and data values. This method returns the radii and values of the data points within the maximum radius defined by the input radii. """ shape = self.data.shape max_radius = np.max(self.radii) x_min = int(max(np.floor(self.xycen[0] - max_radius), 0)) x_max = int(min(np.ceil(self.xycen[0] + max_radius), shape[1])) y_min = int(max(np.floor(self.xycen[1] - max_radius), 0)) y_max = int(min(np.ceil(self.xycen[1] + max_radius), shape[0])) yidx, xidx = np.indices((y_max - y_min, x_max - x_min)) xidx += x_min yidx += y_min radii = np.hypot(xidx - self.xycen[0], yidx - self.xycen[1]) mask = radii <= max_radius radii = radii[mask] data_values = self.data[yidx[mask], xidx[mask]] return radii, data_values @lazyproperty def data_radius(self): """ The radii of the raw data profile as a 1D `~numpy.ndarray`. """ return self._data_profile[0] @lazyproperty def data_profile(self): """ The raw data profile as a 1D `~numpy.ndarray`. """ return self._data_profile[1] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.7099268 photutils-2.2.0/photutils/profiles/tests/0000755000175100001660000000000014755160634020204 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/profiles/tests/__init__.py0000644000175100001660000000000014755160622022300 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/profiles/tests/test_curve_of_growth.py0000644000175100001660000001525614755160622025025 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the core module. """ import astropy.units as u import numpy as np import pytest from astropy.modeling.models import Gaussian2D from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_equal from photutils.aperture import CircularAperture from photutils.profiles import CurveOfGrowth from photutils.utils._optional_deps import HAS_MATPLOTLIB @pytest.fixture(name='profile_data') def fixture_profile_data(): xsize = 101 ysize = 80 xcen = (xsize - 1) / 2 ycen = (ysize - 1) / 2 xycen = (xcen, ycen) sig = 10.0 model = Gaussian2D(21., xcen, ycen, sig, sig) y, x = np.mgrid[0:ysize, 0:xsize] data = model(x, y) error = 10.0 * np.sqrt(data) mask = np.zeros(data.shape, dtype=bool) mask[:int(ycen), :int(xcen)] = True return xycen, data, error, mask def test_curve_of_growth(profile_data): xycen, data, _, _ = profile_data radii = np.arange(1, 37) cg1 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) assert_equal(cg1.radius, radii) assert cg1.area.shape == (36,) assert cg1.profile.shape == (36,) assert cg1.profile_error.shape == (0,) assert_allclose(cg1.area[0], np.pi) assert len(cg1.apertures) == 36 assert isinstance(cg1.apertures[0], CircularAperture) radii = np.arange(1, 36) cg2 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) assert cg2.area[0] > 0.0 assert isinstance(cg2.apertures[0], CircularAperture) def test_curve_of_growth_units(profile_data): xycen, data, error, _ = profile_data radii = np.arange(1, 36) unit = u.Jy cg1 = CurveOfGrowth(data << unit, xycen, radii, error=error << unit, mask=None) assert cg1.profile.unit == unit assert cg1.profile_error.unit == unit match = 'must all have the same units' with pytest.raises(ValueError, match=match): CurveOfGrowth(data << unit, xycen, radii, error=error, mask=None) def test_curve_of_growth_error(profile_data): xycen, data, error, _ = profile_data radii = np.arange(1, 36) cg1 = CurveOfGrowth(data, xycen, radii, error=error, mask=None) assert cg1.profile.shape == (35,) assert cg1.profile_error.shape == (35,) def test_curve_of_growth_mask(profile_data): xycen, data, error, mask = profile_data radii = np.arange(1, 36) cg1 = CurveOfGrowth(data, xycen, radii, error=error, mask=None) cg2 = CurveOfGrowth(data, xycen, radii, error=error, mask=mask) assert cg1.profile.sum() > cg2.profile.sum() assert np.sum(cg1.profile_error**2) > np.sum(cg2.profile_error**2) def test_curve_of_growth_normalize(profile_data): xycen, data, _, _ = profile_data radii = np.arange(1, 36) cg1 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) cg2 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) profile1 = cg1.profile cg1.normalize() profile2 = cg1.profile assert np.mean(profile2) < np.mean(profile1) cg1.unnormalize() assert_allclose(cg1.profile, cg2.profile) cg1.normalize(method='sum') cg1.normalize(method='max') cg1.unnormalize() assert_allclose(cg1.profile, cg2.profile) cg1.normalize(method='max') cg1.normalize(method='sum') cg1.normalize(method='max') cg1.normalize(method='max') cg1.unnormalize() assert_allclose(cg1.profile, cg2.profile) cg1.normalize(method='sum') profile3 = cg1.profile assert np.mean(profile3) < np.mean(profile1) cg1.unnormalize() assert_allclose(cg1.profile, cg2.profile) match = 'invalid method, must be "max" or "sum"' with pytest.raises(ValueError, match=match): cg1.normalize(method='invalid') cg1.__dict__['profile'] -= np.max(cg1.__dict__['profile']) match = 'The profile cannot be normalized' with pytest.warns(AstropyUserWarning, match=match): cg1.normalize(method='max') def test_curve_of_growth_interp(profile_data): xycen, data, error, _ = profile_data radii = np.arange(1, 36) cg1 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) cg1.normalize() ee_radii = np.array([0, 5, 10, 20, 25, 50], dtype=float) ee_vals = cg1.calc_ee_at_radius(ee_radii) ee_expected = np.array([np.nan, 0.1176754, 0.39409357, 0.86635049, 0.95805792, np.nan]) assert_allclose(ee_vals, ee_expected, rtol=1e-6) rr = cg1.calc_radius_at_ee(ee_vals) ee_radii[[0, -1]] = np.nan assert_allclose(rr, ee_radii, rtol=1e-6) radii = np.linspace(0.1, 36, 200) cg1 = CurveOfGrowth(data, xycen, radii, error=None, mask=None, method='center') ee_vals = cg1.calc_ee_at_radius(ee_radii) match = 'The curve-of-growth profile is not monotonically increasing' with pytest.raises(ValueError, match=match): cg1.calc_radius_at_ee(ee_vals) def test_curve_of_growth_inputs(profile_data): xycen, data, error, _ = profile_data 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) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_curve_of_growth_plot(profile_data): xycen, data, error, _ = profile_data radii = np.arange(1, 36) cg1 = CurveOfGrowth(data, xycen, radii, error=None, mask=None) cg1.plot() 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() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/profiles/tests/test_radial_profile.py0000644000175100001660000001525714755160622024600 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the core module. """ import astropy.units as u import numpy as np import pytest from astropy.modeling.models import Gaussian1D, Gaussian2D from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_equal from photutils.aperture import CircularAnnulus, CircularAperture from photutils.profiles import RadialProfile @pytest.fixture(name='profile_data') def fixture_profile_data(): xsize = 101 ysize = 80 xcen = (xsize - 1) / 2 ycen = (ysize - 1) / 2 xycen = (xcen, ycen) sig = 10.0 model = Gaussian2D(21., xcen, ycen, sig, sig) y, x = np.mgrid[0:ysize, 0:xsize] data = model(x, y) error = 10.0 * np.sqrt(data) mask = np.zeros(data.shape, dtype=bool) mask[:int(ycen), :int(xcen)] = True return xycen, data, error, mask def test_radial_profile(profile_data): xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp1 = RadialProfile(data, xycen, edge_radii, error=None, mask=None) assert_equal(rp1.radius, np.arange(35) + 0.5) assert rp1.area.shape == (35,) assert rp1.profile.shape == (35,) assert rp1.profile_error.shape == (0,) assert rp1.area[0] > 0.0 assert len(rp1.apertures) == 35 assert isinstance(rp1.apertures[0], CircularAperture) assert isinstance(rp1.apertures[1], CircularAnnulus) edge_radii = np.arange(36) + 0.1 rp2 = RadialProfile(data, xycen, edge_radii, error=None, mask=None) assert isinstance(rp2.apertures[0], CircularAnnulus) def test_radial_profile_normalization(profile_data): 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_radial_profile_data(profile_data): 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_radial_profile_inputs(profile_data): xycen, data, _, _ = profile_data match = 'minimum radii must be >= 0' edge_radii = np.arange(-1, 10) with pytest.raises(ValueError, match=match): RadialProfile(data, xycen, edge_radii, error=None, mask=None) match = 'radii must be a 1D array and have at least two values' edge_radii = [1] with pytest.raises(ValueError, match=match): RadialProfile(data, xycen, edge_radii, error=None, mask=None) edge_radii = np.arange(6).reshape(2, 3) with pytest.raises(ValueError, match=match): RadialProfile(data, xycen, edge_radii, error=None, mask=None) match = 'radii must be strictly increasing' edge_radii = np.arange(10)[::-1] with pytest.raises(ValueError, match=match): RadialProfile(data, xycen, edge_radii, error=None, mask=None) match = 'error must have the same shape as data' edge_radii = np.arange(10) with pytest.raises(ValueError, match=match): RadialProfile(data, xycen, edge_radii, error=np.ones(3), mask=None) match = 'mask must have the same shape as data' edge_radii = np.arange(10) mask = np.ones(3, dtype=bool) with pytest.raises(ValueError, match=match): RadialProfile(data, xycen, edge_radii, error=None, mask=mask) def test_radial_profile_gaussian(profile_data): xycen, data, _, _ = profile_data edge_radii = np.arange(36) rp1 = RadialProfile(data, xycen, edge_radii, error=None, mask=None) assert isinstance(rp1.gaussian_fit, Gaussian1D) assert rp1.gaussian_profile.shape == (35,) assert rp1.gaussian_fwhm < 23.6 edge_radii = np.arange(201) rp2 = RadialProfile(data, xycen, edge_radii, error=None, mask=None) assert isinstance(rp2.gaussian_fit, Gaussian1D) assert rp2.gaussian_profile.shape == (200,) assert rp2.gaussian_fwhm < 23.6 def test_radial_profile_unit(profile_data): xycen, data, error, _ = profile_data edge_radii = np.arange(36) unit = u.Jy rp1 = RadialProfile(data << unit, xycen, edge_radii, error=error << unit, mask=None) assert rp1.profile.unit == unit assert rp1.profile_error.unit == unit 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_radial_profile_error(profile_data): xycen, data, error, _ = profile_data edge_radii = np.arange(36) rp1 = RadialProfile(data, xycen, edge_radii, error=error, mask=None) assert_equal(rp1.radius, np.arange(35) + 0.5) assert rp1.area.shape == (35,) assert rp1.profile.shape == (35,) assert rp1.profile_error.shape == (35,) assert len(rp1.apertures) == 35 assert isinstance(rp1.apertures[0], CircularAperture) assert isinstance(rp1.apertures[1], CircularAnnulus) def test_radial_profile_normalize_nan(profile_data): """ If the profile has NaNs (e.g., aperture outside of the image), make sure the normalization ignores them. """ xycen, data, _, _ = profile_data edge_radii = np.arange(101) rp1 = RadialProfile(data, xycen, edge_radii) rp1.normalize() assert not np.isnan(rp1.profile[0]) def test_radial_profile_nonfinite(profile_data): xycen, data, error, _ = profile_data data2 = data.copy() data2[40, 40] = np.nan mask = ~np.isfinite(data2) edge_radii = np.arange(36) rp1 = RadialProfile(data, xycen, edge_radii, error=None, mask=mask) rp2 = RadialProfile(data2, xycen, edge_radii, error=error, mask=mask) assert_allclose(rp1.profile, rp2.profile) match = 'Input data contains non-finite values' with pytest.warns(AstropyUserWarning, match=match): rp3 = RadialProfile(data2, xycen, edge_radii, error=error, mask=None) assert_allclose(rp1.profile, rp3.profile) error2 = error.copy() error2[40, 40] = np.inf with pytest.warns(AstropyUserWarning, match=match): rp4 = RadialProfile(data, xycen, edge_radii, error=error2, mask=None) assert_allclose(rp1.profile, rp4.profile) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.7129269 photutils-2.2.0/photutils/psf/0000755000175100001660000000000014755160634016007 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/__init__.py0000644000175100001660000000131014755160622020110 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools to perform point-spread-function (PSF) photometry. """ from .epsf import * # noqa: F401, F403 from .epsf_stars 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 .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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/epsf.py0000644000175100001660000010050314755160622017312 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools to build and fit an effective PSF (ePSF) based on Anderson and King (2000; PASP 112, 1360) and Anderson (2016; WFC3 ISR 2016-12). """ import copy import warnings import numpy as np from astropy.modeling.fitting import TRFLSQFitter from astropy.nddata.utils import NoOverlapError, PartialOverlapError from astropy.stats import SigmaClip from astropy.utils.exceptions import AstropyUserWarning from scipy.ndimage import convolve from photutils.centroids import centroid_com from photutils.psf.epsf_stars import EPSFStar, EPSFStars, LinkedEPSFStar from photutils.psf.image_models import ImagePSF, _LegacyEPSFModel from photutils.psf.utils import _interpolate_missing_data from photutils.utils._parameters import as_pair from photutils.utils._progress_bars import add_progress_bar from photutils.utils._round import py2intround from photutils.utils._stats import nanmedian from photutils.utils.cutouts import _overlap_slices as overlap_slices __all__ = ['EPSFBuilder', 'EPSFFitter'] class EPSFFitter: """ Class to fit an ePSF model to one or more stars. Parameters ---------- fitter : `astropy.modeling.fitting.Fitter`, optional A `~astropy.modeling.fitting.Fitter` object. fit_boxsize : int, tuple of int, or `None`, optional The size (in pixels) of the box centered on the star to be used for ePSF fitting. This allows using only a small number of central pixels of the star (i.e., where the star is brightest) for fitting. If ``fit_boxsize`` is a scalar then a square box of size ``fit_boxsize`` will be used. If ``fit_boxsize`` has two elements, they must be in ``(ny, nx)`` order. ``fit_boxsize`` must have odd values and be greater than or equal to 3 for both axes. If `None`, the fitter will use the entire star image. **fitter_kwargs : dict, 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=TRFLSQFitter(), fit_boxsize=5, **fitter_kwargs): self.fitter = fitter self.fitter_has_fit_info = hasattr(self.fitter, 'fit_info') self.fit_boxsize = as_pair('fit_boxsize', fit_boxsize, lower_bound=(3, 0), check_odd=True) # remove any fitter keyword arguments that we need to set remove_kwargs = ['x', 'y', 'z', 'weights'] fitter_kwargs = copy.deepcopy(fitter_kwargs) for kwarg in remove_kwargs: if kwarg in fitter_kwargs: del fitter_kwargs[kwarg] self.fitter_kwargs = fitter_kwargs def __call__(self, epsf, stars): """ Fit an ePSF model to stars. Parameters ---------- epsf : `ImagePSF` An ePSF model to be fitted to the stars. stars : `EPSFStars` object The stars to be fit. The center coordinates for each star should be as close as possible to actual centers. For stars than contain weights, a weighted fit of the ePSF to the star will be performed. Returns ------- fitted_stars : `EPSFStars` object The fitted stars. The ePSF-fitted center position and flux are stored in the ``center`` (and ``cutout_center``) and ``flux`` attributes. """ if len(stars) == 0: return stars if not isinstance(epsf, ImagePSF): raise TypeError('The input epsf must be an ImagePSF.') epsf = _LegacyEPSFModel(epsf.data, flux=epsf.flux, x_0=epsf.x_0, y_0=epsf.y_0, oversampling=epsf.oversampling, fill_value=epsf.fill_value) # make a copy of the input ePSF epsf = epsf.copy() # perform the fit fitted_stars = [] for star in stars: if isinstance(star, EPSFStar): fitted_star = self._fit_star(epsf, star, self.fitter, self.fitter_kwargs, self.fitter_has_fit_info, self.fit_boxsize) elif isinstance(star, LinkedEPSFStar): fitted_star = [] for linked_star in star: fitted_star.append( self._fit_star(epsf, linked_star, self.fitter, self.fitter_kwargs, self.fitter_has_fit_info, self.fit_boxsize)) fitted_star = LinkedEPSFStar(fitted_star) fitted_star.constrain_centers() else: raise TypeError('stars must contain only EPSFStar and/or ' 'LinkedEPSFStar objects.') fitted_stars.append(fitted_star) return EPSFStars(fitted_stars) def _fit_star(self, epsf, star, fitter, fitter_kwargs, fitter_has_fit_info, fit_boxsize): """ Fit an ePSF model to a single star. The input ``epsf`` will usually be modified by the fitting routine in this function. Make a copy before calling this function if the original is needed. """ if fit_boxsize is not None: try: xcenter, ycenter = star.cutout_center large_slc, _ = overlap_slices(star.shape, fit_boxsize, (ycenter, xcenter), mode='strict') except (PartialOverlapError, NoOverlapError): warnings.warn(f'The star at ({star.center[0]}, ' f'{star.center[1]}) cannot be fit because ' 'its fitting region extends beyond the star ' 'cutout image.', AstropyUserWarning) star = copy.deepcopy(star) star._fit_error_status = 1 return star data = star.data[large_slc] weights = star.weights[large_slc] # define the origin of the fitting region x0 = large_slc[1].start y0 = large_slc[0].start else: # use the entire cutout image data = star.data weights = star.weights # define the origin of the fitting region x0 = 0 y0 = 0 # Define positions in the undersampled grid. The fitter will # evaluate on the defined interpolation grid, currently in the # range [0, len(undersampled grid)]. yy, xx = np.indices(data.shape, dtype=float) xx = xx + x0 - star.cutout_center[0] yy = yy + y0 - star.cutout_center[1] # define the initial guesses for fitted flux and shifts epsf.flux = star.flux epsf.x_0 = 0.0 epsf.y_0 = 0.0 try: fitted_epsf = fitter(model=epsf, x=xx, y=yy, z=data, weights=weights, **fitter_kwargs) except TypeError: # fitter doesn't support weights fitted_epsf = fitter(model=epsf, x=xx, y=yy, z=data, **fitter_kwargs) fit_error_status = 0 if fitter_has_fit_info: fit_info = copy.copy(fitter.fit_info) if 'ierr' in fit_info and fit_info['ierr'] not in [1, 2, 3, 4]: fit_error_status = 2 # fit solution was not found else: fit_info = None # compute the star's fitted position x_center = star.cutout_center[0] + fitted_epsf.x_0.value y_center = star.cutout_center[1] + fitted_epsf.y_0.value star = copy.deepcopy(star) star.cutout_center = (x_center, y_center) # set the star's flux to the ePSF-fitted flux star.flux = fitted_epsf.flux.value star._fit_info = fit_info star._fit_error_status = fit_error_status return star class EPSFBuilder: """ Class to build an effective PSF (ePSF). See `Anderson and King (2000; PASP 112, 1360) `_ and `Anderson (2016; WFC3 ISR 2016-12) `_ for details. Parameters ---------- oversampling : int or array_like (int) The integer oversampling factor(s) of the ePSF relative to the input ``stars`` along each axis. If ``oversampling`` is a scalar then it will be used for both axes. If ``oversampling`` has two elements, they must be in ``(y, x)`` order. shape : float, tuple of two floats, or `None`, optional The shape of the output ePSF. If the ``shape`` is not `None`, it will be derived from the sizes of the input ``stars`` and the ePSF oversampling factor. If the size is even along any axis, it will be made odd by adding one. The output ePSF will always have odd sizes along both axes to ensure a well-defined central pixel. smoothing_kernel : {'quartic', 'quadratic'}, 2D `~numpy.ndarray`, or `None` The smoothing kernel to apply to the ePSF. The predefined ``'quartic'`` and ``'quadratic'`` kernels are derived from fourth and second degree polynomials, respectively. Alternatively, a custom 2D array can be input. If `None` then no smoothing will be performed. recentering_func : callable, optional A callable object (e.g., function or class) that is used to calculate the centroid of a 2D array. The callable must accept a 2D `~numpy.ndarray`, have a ``mask`` keyword and optionally ``error`` and ``oversampling`` keywords. The callable object must return a tuple of two 1D `~numpy.ndarray` variables, representing the x and y centroids. recentering_maxiters : int, optional The maximum number of recentering iterations to perform during each ePSF build iteration. fitter : `EPSFFitter` object, optional A `EPSFFitter` object use to fit the ePSF to stars. To set custom fitter options, input a new `EPSFFitter` object. See the `EPSFFitter` documentation for options. maxiters : int, optional The maximum number of iterations to perform. progress_bar : bool, option Whether to print the progress bar during the build iterations. The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. norm_radius : float, optional The pixel radius over which the ePSF is normalized. recentering_boxsize : float or tuple of two floats, optional The size (in pixels) of the box used to calculate the centroid of the ePSF during each build iteration. If a single integer number is provided, then a square box will be used. If two values are provided, then they must be in ``(ny, nx)`` order. ``recentering_boxsize`` must have odd must have odd values and be greater than or equal to 3 for both axes. center_accuracy : float, optional The desired accuracy for the centers of stars. The building iterations will stop if the centers of all the stars change by less than ``center_accuracy`` pixels between iterations. All stars must meet this condition for the loop to exit. sigma_clip : `astropy.stats.SigmaClip` instance, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters used to determine which pixels are ignored when stacking the ePSF residuals in each iteration step. If `None` then no sigma clipping will be performed. Notes ----- If your image contains NaN values, you may see better performance if you have the `bottleneck`_ package installed. .. _bottleneck: https://github.com/pydata/bottleneck """ def __init__(self, *, oversampling=4, shape=None, smoothing_kernel='quartic', recentering_func=centroid_com, recentering_maxiters=20, fitter=EPSFFitter(), maxiters=10, progress_bar=True, norm_radius=5.5, recentering_boxsize=(5, 5), center_accuracy=1.0e-3, sigma_clip=SigmaClip(sigma=3, cenfunc='median', maxiters=10)): if oversampling is None: raise ValueError("'oversampling' must be specified.") self.oversampling = as_pair('oversampling', oversampling, lower_bound=(0, 1)) self._norm_radius = norm_radius if shape is not None: self.shape = as_pair('shape', shape, lower_bound=(0, 1)) else: self.shape = shape self.recentering_func = recentering_func self.recentering_maxiters = recentering_maxiters self.recentering_boxsize = as_pair('recentering_boxsize', recentering_boxsize, lower_bound=(3, 0), check_odd=True) self.smoothing_kernel = smoothing_kernel if not isinstance(fitter, EPSFFitter): raise TypeError('fitter must be an EPSFFitter instance.') self.fitter = fitter if center_accuracy <= 0.0: raise ValueError('center_accuracy must be a positive number.') self.center_accuracy_sq = center_accuracy**2 maxiters = int(maxiters) if maxiters <= 0: raise ValueError("'maxiters' must be a positive number.") self.maxiters = maxiters self.progress_bar = progress_bar if not isinstance(sigma_clip, SigmaClip): raise TypeError('sigma_clip must be an astropy.stats.SigmaClip ' 'instance.') self._sigma_clip = sigma_clip # store each ePSF build iteration self._epsf = [] def __call__(self, stars): return self.build_epsf(stars) def _create_initial_epsf(self, stars): """ Create an initial `_LegacyEPSFModel` object. The initial ePSF data are all zeros. If ``shape`` is not specified, the shape of the ePSF data array is determined from the shape of the input ``stars`` and the oversampling factor. If the size is even along any axis, it will be made odd by adding one. The output ePSF will always have odd sizes along both axes to ensure a central pixel. Parameters ---------- stars : `EPSFStars` object The stars used to build the ePSF. Returns ------- epsf : `_LegacyEPSFModel` The initial ePSF model. """ norm_radius = self._norm_radius oversampling = self.oversampling shape = self.shape # define the ePSF shape if shape is not None: shape = as_pair('shape', shape, lower_bound=(0, 1), check_odd=True) else: # Stars class should have odd-sized dimensions, and thus we # get the oversampled shape as oversampling * len + 1; if # len=25, then newlen=101, for example. x_shape = (np.ceil(stars._max_shape[0]) * oversampling[1] + 1).astype(int) y_shape = (np.ceil(stars._max_shape[1]) * oversampling[0] + 1).astype(int) shape = np.array((y_shape, x_shape)) # verify odd sizes of shape shape = [(i + 1) if i % 2 == 0 else i for i in shape] data = np.zeros(shape, dtype=float) # ePSF origin should be in the undersampled pixel units, not the # oversampled grid units. The middle, fractional (as we wish for # the center of the pixel, so the center should be at (v.5, w.5) # detector pixels) value is simply the average of the two values # at the extremes. xcenter = stars._max_shape[0] / 2.0 ycenter = stars._max_shape[1] / 2.0 return _LegacyEPSFModel(data=data, origin=(xcenter, ycenter), oversampling=oversampling, norm_radius=norm_radius) def _resample_residual(self, star, epsf): """ Compute a normalized residual image in the oversampled ePSF grid. A normalized residual image is calculated by subtracting the normalized ePSF model from the normalized star at the location of the star in the undersampled grid. The normalized residual image is then resampled from the undersampled star grid to the oversampled ePSF grid. Parameters ---------- star : `EPSFStar` object A single star object. epsf : `_LegacyEPSFModel` object The ePSF model. Returns ------- image : 2D `~numpy.ndarray` A 2D image containing the resampled residual image. The image contains NaNs where there is no data. """ # Compute the normalized residual by subtracting the ePSF model # from the normalized star at the location of the star in the # undersampled grid. x = star._xidx_centered y = star._yidx_centered stardata = (star._data_values_normalized - epsf.evaluate(x=x, y=y, flux=1.0, x_0=0.0, y_0=0.0)) x = epsf.oversampling[1] * star._xidx_centered y = epsf.oversampling[0] * star._yidx_centered epsf_xcenter, epsf_ycenter = (int((epsf.data.shape[1] - 1) / 2), int((epsf.data.shape[0] - 1) / 2)) xidx = py2intround(x + epsf_xcenter) yidx = py2intround(y + epsf_ycenter) resampled_img = np.full(epsf.shape, np.nan) mask = np.logical_and(np.logical_and(xidx >= 0, xidx < epsf.shape[1]), np.logical_and(yidx >= 0, yidx < epsf.shape[0])) xidx_ = xidx[mask] yidx_ = yidx[mask] resampled_img[yidx_, xidx_] = stardata[mask] return resampled_img def _resample_residuals(self, stars, epsf): """ Compute normalized residual images for all the input stars. Parameters ---------- stars : `EPSFStars` object The stars used to build the ePSF. epsf : `_LegacyEPSFModel` object The ePSF model. Returns ------- epsf_resid : 3D `~numpy.ndarray` A 3D cube containing the resampled residual images. """ shape = (stars.n_good_stars, epsf.shape[0], epsf.shape[1]) epsf_resid = np.zeros(shape) for i, star in enumerate(stars.all_good_stars): epsf_resid[i, :, :] = self._resample_residual(star, epsf) return epsf_resid def _smooth_epsf(self, epsf_data): """ Smooth the ePSF array by convolving it with a kernel. Parameters ---------- epsf_data : 2D `~numpy.ndarray` A 2D array containing the ePSF image. Returns ------- result : 2D `~numpy.ndarray` The smoothed (convolved) ePSF data. """ if self.smoothing_kernel is None: return epsf_data # do this check first as comparing a ndarray to string causes a warning if isinstance(self.smoothing_kernel, np.ndarray): kernel = self.smoothing_kernel elif self.smoothing_kernel == 'quartic': # from Polynomial2D fit with degree=4 to 5x5 array of # zeros with 1.0 at the center # Polynomial2D(4, c0_0=0.04163265, c1_0=-0.76326531, # c2_0=0.99081633, c3_0=-0.4, c4_0=0.05, # c0_1=-0.76326531, c0_2=0.99081633, c0_3=-0.4, # c0_4=0.05, c1_1=0.32653061, c1_2=-0.08163265, # c1_3=0.0, c2_1=-0.08163265, c2_2=0.02040816, # c3_1=-0.0)> kernel = np.array( [[+0.041632, -0.080816, 0.078368, -0.080816, +0.041632], [-0.080816, -0.019592, 0.200816, -0.019592, -0.080816], [+0.078368, +0.200816, 0.441632, +0.200816, +0.078368], [-0.080816, -0.019592, 0.200816, -0.019592, -0.080816], [+0.041632, -0.080816, 0.078368, -0.080816, +0.041632]]) elif self.smoothing_kernel == 'quadratic': # from Polynomial2D fit with degree=2 to 5x5 array of # zeros with 1.0 at the center # Polynomial2D(2, c0_0=-0.07428571, c1_0=0.11428571, # c2_0=-0.02857143, c0_1=0.11428571, # c0_2=-0.02857143, c1_1=-0.0) kernel = np.array( [[-0.07428311, 0.01142786, 0.03999952, 0.01142786, -0.07428311], [+0.01142786, 0.09714283, 0.12571449, 0.09714283, +0.01142786], [+0.03999952, 0.12571449, 0.15428215, 0.12571449, +0.03999952], [+0.01142786, 0.09714283, 0.12571449, 0.09714283, +0.01142786], [-0.07428311, 0.01142786, 0.03999952, 0.01142786, -0.07428311]]) else: raise TypeError('Unsupported kernel.') return convolve(epsf_data, kernel) def _recenter_epsf(self, epsf, centroid_func=centroid_com, box_size=(5, 5), maxiters=20, center_accuracy=1.0e-4): """ Calculate the center of the ePSF data and shift the data so the ePSF center is at the center of the ePSF data array. Parameters ---------- epsf : `_LegacyEPSFModel` object The ePSF model. centroid_func : callable, optional A callable object (e.g., function or class) that is used to calculate the centroid of a 2D array. The callable must accept a 2D `~numpy.ndarray`, have a ``mask`` keyword and optionally an ``error`` keyword. The callable object must return a tuple of two 1D `~numpy.ndarray` variables, representing the x and y centroids. box_size : float or tuple of two floats, optional The size (in pixels) of the box used to calculate the centroid of the ePSF during each build iteration. If a single integer number is provided, then a square box will be used. If two values are provided, then they must be in ``(ny, nx)`` order. ``box_size`` must have odd values and be greater than or equal to 3 for both axes. maxiters : int, optional The maximum number of recentering iterations to perform. center_accuracy : float, optional The desired accuracy for the centers of stars. The building iterations will stop if the center of the ePSF changes by less than ``center_accuracy`` pixels between iterations. Returns ------- result : 2D `~numpy.ndarray` The recentered ePSF data. """ epsf_data = epsf._data epsf = _LegacyEPSFModel(data=epsf._data, origin=epsf.origin, oversampling=epsf.oversampling, norm_radius=epsf._norm_radius, normalize=False) xcenter, ycenter = epsf.origin y, x = np.indices(epsf._data.shape, dtype=float) x /= epsf.oversampling[1] y /= epsf.oversampling[0] dx_total, dy_total = 0, 0 iter_num = 0 center_accuracy_sq = center_accuracy**2 center_dist_sq = center_accuracy_sq + 1.0e6 center_dist_sq_prev = center_dist_sq + 1 while (iter_num < maxiters and center_dist_sq >= center_accuracy_sq): iter_num += 1 # Anderson & King (2000) recentering function depends # on specific pixels, and thus does not need a cutout slices_large, _ = overlap_slices(epsf_data.shape, box_size, (ycenter * self.oversampling[0], xcenter * self.oversampling[1])) epsf_cutout = epsf_data[slices_large] mask = ~np.isfinite(epsf_cutout) # find a new center position xcenter_new, ycenter_new = centroid_func(epsf_cutout, mask=mask) xcenter_new /= self.oversampling[1] ycenter_new /= self.oversampling[0] xcenter_new += slices_large[1].start / self.oversampling[1] ycenter_new += slices_large[0].start / self.oversampling[0] # Calculate the shift; dx = i - x_star so if dx was positively # incremented then x_star was negatively incremented for a given i. # We will therefore actually subsequently subtract dx from xcenter # (or x_star). dx = xcenter_new - xcenter dy = ycenter_new - ycenter center_dist_sq = dx**2 + dy**2 if center_dist_sq >= center_dist_sq_prev: # don't shift break center_dist_sq_prev = center_dist_sq dx_total += dx dy_total += dy epsf_data = epsf.evaluate(x=x, y=y, flux=1.0, x_0=xcenter - dx_total, y_0=ycenter - dy_total) return epsf_data def _build_epsf_step(self, stars, epsf=None): """ A single iteration of improving an ePSF. Parameters ---------- stars : `EPSFStars` object The stars used to build the ePSF. epsf : `_LegacyEPSFModel` object, optional The initial ePSF model. If not input, then the ePSF will be built from scratch. Returns ------- epsf : `_LegacyEPSFModel` object The updated ePSF. """ if len(stars) < 1: raise ValueError('stars must contain at least one EPSFStar or ' 'LinkedEPSFStar object.') if epsf is None: # create an initial ePSF (array of zeros) epsf = self._create_initial_epsf(stars) else: # improve the input ePSF epsf = copy.deepcopy(epsf) # compute a 3D stack of 2D residual images residuals = self._resample_residuals(stars, epsf) # compute the sigma-clipped average along the 3D stack with warnings.catch_warnings(): warnings.simplefilter('ignore', category=RuntimeWarning) warnings.simplefilter('ignore', category=AstropyUserWarning) residuals = self._sigma_clip(residuals, axis=0, masked=False, return_bounds=False) residuals = nanmedian(residuals, axis=0) # interpolate any missing data (np.nan) mask = ~np.isfinite(residuals) if np.any(mask): residuals = _interpolate_missing_data(residuals, mask, method='cubic') # fill any remaining nans (outer points) with zeros residuals[~np.isfinite(residuals)] = 0.0 # add the residuals to the previous ePSF image new_epsf = epsf._data + residuals # smooth and recenter the ePSF new_epsf = self._smooth_epsf(new_epsf) epsf = _LegacyEPSFModel(data=new_epsf, origin=epsf.origin, oversampling=epsf.oversampling, norm_radius=epsf._norm_radius, normalize=False) epsf._data = self._recenter_epsf( epsf, centroid_func=self.recentering_func, box_size=self.recentering_boxsize, maxiters=self.recentering_maxiters) # Return the new ePSF object, but with undersampled grid pixel # coordinates. xcenter = (epsf._data.shape[1] - 1) / 2.0 / epsf.oversampling[1] ycenter = (epsf._data.shape[0] - 1) / 2.0 / epsf.oversampling[0] return _LegacyEPSFModel(data=epsf._data, origin=(xcenter, ycenter), oversampling=epsf.oversampling, norm_radius=epsf._norm_radius) def build_epsf(self, stars, *, init_epsf=None): """ Build iteratively an ePSF from star cutouts. Parameters ---------- stars : `EPSFStars` object The stars used to build the ePSF. init_epsf : `ImagePSF` object, optional The initial ePSF model. If not input, then the ePSF will be built from scratch. Returns ------- epsf : `ImagePSF` object The constructed ePSF. fitted_stars : `EPSFStars` object The input stars with updated centers and fluxes derived from fitting the output ``epsf``. """ iter_num = 0 fit_failed = np.zeros(stars.n_stars, dtype=bool) epsf = init_epsf center_dist_sq = self.center_accuracy_sq + 1.0 centers = stars.cutout_center_flat pbar = None if self.progress_bar: desc = f'EPSFBuilder ({self.maxiters} maxiters)' pbar = add_progress_bar(total=self.maxiters, desc=desc) # pragma: no cover if epsf is None: legacy_epsf = None else: legacy_epsf = _LegacyEPSFModel(epsf.data, flux=epsf.flux, x_0=epsf.x_0, y_0=epsf.y_0, oversampling=epsf.oversampling, fill_value=epsf.fill_value) while (iter_num < self.maxiters and not np.all(fit_failed) and np.max(center_dist_sq) >= self.center_accuracy_sq): iter_num += 1 # build/improve the ePSF legacy_epsf = self._build_epsf_step(stars, epsf=legacy_epsf) # fit the new ePSF to the stars to find improved centers # we catch fit warnings here -- stars with unsuccessful fits # are excluded from the ePSF build process with warnings.catch_warnings(): message = '.*The fit may be unsuccessful;.*' warnings.filterwarnings('ignore', message=message, category=AstropyUserWarning) image_psf = ImagePSF(data=legacy_epsf.data, flux=legacy_epsf.flux, x_0=legacy_epsf.x_0, y_0=legacy_epsf.y_0, oversampling=legacy_epsf.oversampling, fill_value=legacy_epsf.fill_value) stars = self.fitter(image_psf, stars) # find all stars where the fit failed fit_failed = np.array([star._fit_error_status > 0 for star in stars.all_stars]) if np.all(fit_failed): raise ValueError('The ePSF fitting failed for all stars.') # permanently exclude fitting any star where the fit fails # after 3 iterations if iter_num > 3 and np.any(fit_failed): idx = fit_failed.nonzero()[0] for i in idx: # pylint: disable=not-an-iterable stars.all_stars[i]._excluded_from_fit = True # if no star centers have moved by more than pixel accuracy, # stop the iteration loop early dx_dy = stars.cutout_center_flat - centers dx_dy = dx_dy[np.logical_not(fit_failed)] center_dist_sq = np.sum(dx_dy * dx_dy, axis=1, dtype=np.float64) centers = stars.cutout_center_flat self._epsf.append(legacy_epsf) if pbar is not None: pbar.update() if pbar is not None: if iter_num < self.maxiters: pbar.write(f'EPSFBuilder converged after {iter_num} ' f'iterations (of {self.maxiters} maximum ' 'iterations)') pbar.close() epsf = ImagePSF(data=legacy_epsf.data, flux=legacy_epsf.flux, x_0=legacy_epsf.x_0, y_0=legacy_epsf.y_0, oversampling=legacy_epsf.oversampling, fill_value=legacy_epsf.fill_value) return epsf, stars ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/epsf_stars.py0000644000175100001660000006621614755160622020542 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools to extract cutouts of stars and data structures to hold the cutouts for fitting and building ePSFs. """ import warnings import numpy as np from astropy.nddata import NDData, StdDevUncertainty from astropy.nddata.utils import NoOverlapError, PartialOverlapError from astropy.table import Table from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture import BoundingBox from photutils.psf.image_models import _LegacyEPSFModel from photutils.psf.utils import _interpolate_missing_data from photutils.utils._parameters import as_pair from photutils.utils.cutouts import _overlap_slices as overlap_slices __all__ = ['EPSFStar', 'EPSFStars', 'LinkedEPSFStar', 'extract_stars'] class EPSFStar: """ A class to hold a 2D cutout image and associated metadata of a star used to build an ePSF. Parameters ---------- data : `~numpy.ndarray` A 2D cutout image of a single star. weights : `~numpy.ndarray` or `None`, optional A 2D array of the weights associated with the input ``data``. cutout_center : tuple of two floats or `None`, optional The ``(x, y)`` position of the star's center with respect to the input cutout ``data`` array. If `None`, then the center of the input cutout ``data`` array will be used. origin : tuple of two int, optional The ``(x, y)`` index of the origin (bottom-left corner) pixel of the input cutout array with respect to the original array from which the cutout was extracted. This can be used to convert positions within the cutout image to positions in the original image. ``origin`` and ``wcs_large`` must both be input for a linked star (a single star extracted from different images). wcs_large : `None` or WCS object, optional A WCS object associated with the large image from which the cutout array was extracted. It should not be the WCS object of the input cutout ``data`` array. The WCS object must support the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). ``origin`` and ``wcs_large`` must both be input for a linked star (a single star extracted from different images). id_label : int, str, or `None`, optional An optional identification number or label for the star. """ def __init__(self, data, *, weights=None, cutout_center=None, origin=(0, 0), wcs_large=None, id_label=None): self._data = np.asanyarray(data) self.shape = self._data.shape if weights is not None: if weights.shape != data.shape: raise ValueError('weights must have the same shape as the ' 'input data array.') self.weights = np.asanyarray(weights, dtype=float).copy() else: self.weights = np.ones_like(self._data, dtype=float) self.mask = (self.weights <= 0.0) # mask out invalid image data invalid_data = np.logical_not(np.isfinite(self._data)) if np.any(invalid_data): self.weights[invalid_data] = 0.0 self.mask[invalid_data] = True self._cutout_center = cutout_center self.origin = np.asarray(origin) self.wcs_large = wcs_large self.id_label = id_label self.flux = self.estimate_flux() self._excluded_from_fit = False self._fitinfo = None def __array__(self): """ Array representation of the mask data array (e.g., for matplotlib). """ return self._data @property def data(self): """ The 2D cutout image. """ return self._data @property def cutout_center(self): """ A `~numpy.ndarray` of the ``(x, y)`` position of the star's center with respect to the input cutout ``data`` array. """ return self._cutout_center @cutout_center.setter def cutout_center(self, value): if value is None: value = ((self.shape[1] - 1) / 2.0, (self.shape[0] - 1) / 2.0) elif len(value) != 2: raise ValueError('The "cutout_center" attribute must have ' 'two elements in (x, y) form.') self._cutout_center = np.asarray(value) @property def center(self): """ A `~numpy.ndarray` of the ``(x, y)`` position of the star's center in the original (large) image (not the cutout image). """ return self.cutout_center + self.origin @lazyproperty def slices(self): """ A tuple of two slices representing the cutout region with respect to the original (large) image. """ return (slice(self.origin[1], self.origin[1] + self.shape[1]), slice(self.origin[0], self.origin[0] + self.shape[0])) @lazyproperty def bbox(self): """ The minimal `~photutils.aperture.BoundingBox` for the cutout region with respect to the original (large) image. """ return BoundingBox(self.slices[1].start, self.slices[1].stop, self.slices[0].start, self.slices[0].stop) def estimate_flux(self): """ Estimate the star's flux by summing values in the input cutout array. Missing data is filled in by interpolation to better estimate the total flux. Returns ------- flux : float The estimated star's flux. """ if np.any(self.mask): data_interp = _interpolate_missing_data(self.data, method='cubic', mask=self.mask) data_interp = _interpolate_missing_data(data_interp, method='nearest', mask=self.mask) flux = np.sum(data_interp, dtype=float) else: flux = np.sum(self.data, dtype=float) return flux def register_epsf(self, epsf): """ Register and scale (in flux) the input ``epsf`` to the star. Parameters ---------- epsf : `ImagePSF` The ePSF to register. Returns ------- data : `~numpy.ndarray` A 2D array of the registered/scaled ePSF. """ legacy_epsf = _LegacyEPSFModel(epsf.data, flux=epsf.flux, x_0=epsf.x_0, y_0=epsf.y_0, oversampling=epsf.oversampling, fill_value=epsf.fill_value) yy, xx = np.indices(self.shape, dtype=float) xx = xx - self.cutout_center[0] yy = yy - self.cutout_center[1] return self.flux * legacy_epsf.evaluate(xx, yy, flux=1.0, x_0=0.0, y_0=0.0) def compute_residual_image(self, epsf): """ Compute the residual image of the star data minus the registered/scaled ePSF. Parameters ---------- epsf : `ImagePSF` The ePSF to subtract. Returns ------- data : `~numpy.ndarray` A 2D array of the residual image. """ return self.data - self.register_epsf(epsf) @lazyproperty def _xy_idx(self): """ 1D arrays of x and y indices of unmasked pixels in the cutout reference frame. """ yidx, xidx = np.indices(self._data.shape) return xidx[~self.mask].ravel(), yidx[~self.mask].ravel() @lazyproperty def _xidx(self): """ 1D arrays of x indices of unmasked pixels in the cutout reference frame. """ return self._xy_idx[0] @lazyproperty def _yidx(self): """ 1D arrays of y indices of unmasked pixels in the cutout reference frame. """ return self._xy_idx[1] @property def _xidx_centered(self): """ 1D array of x indices of unmasked pixels, with respect to the star center, in the cutout reference frame. """ return self._xy_idx[0] - self.cutout_center[0] @property def _yidx_centered(self): """ 1D array of y indices of unmasked pixels, with respect to the star center, in the cutout reference frame. """ return self._xy_idx[1] - self.cutout_center[1] @lazyproperty def _data_values(self): """ 1D array of unmasked cutout data values. """ return self.data[~self.mask].ravel() @lazyproperty def _data_values_normalized(self): """ 1D array of unmasked cutout data values, normalized by the star's total flux. """ return self._data_values / self.flux @lazyproperty def _weight_values(self): """ 1D array of unmasked weight values. """ return self.weights[~self.mask].ravel() class EPSFStars: """ Class to hold a list of `EPSFStar` and/or `LinkedEPSFStar` objects. Parameters ---------- stars_list : list of `EPSFStar` or `LinkedEPSFStar` objects A list of `EPSFStar` and/or `LinkedEPSFStar` objects. """ def __init__(self, stars_list): if isinstance(stars_list, (EPSFStar, LinkedEPSFStar)): self._data = [stars_list] elif isinstance(stars_list, list): self._data = stars_list else: raise TypeError('stars_list must be a list of EPSFStar and/or ' 'LinkedEPSFStar objects.') def __len__(self): return len(self._data) def __getitem__(self, index): return self.__class__(self._data[index]) def __delitem__(self, index): del self._data[index] def __iter__(self): yield from self._data # explicit set/getstate to avoid infinite recursion # from pickler using __getattr__ def __getstate__(self): return self.__dict__ def __setstate__(self, d): self.__dict__ = d def __getattr__(self, attr): if attr in ['cutout_center', 'center', 'flux', '_excluded_from_fit']: result = np.array([getattr(star, attr) for star in self._data]) else: result = [getattr(star, attr) for star in self._data] if len(self._data) == 1: result = result[0] return result def _getattr_flat(self, attr): values = [] for item in self._data: if isinstance(item, LinkedEPSFStar): values.extend(getattr(item, attr)) else: values.append(getattr(item, attr)) return np.array(values) @property def cutout_center_flat(self): """ A `~numpy.ndarray` of the ``(x, y)`` position of all the stars' centers (including linked stars) with respect to the input cutout ``data`` array, as a 2D array (``n_all_stars`` x 2). Note that when `EPSFStars` contains any `LinkedEPSFStar`, the ``cutout_center`` attribute will be a nested 3D array. """ return self._getattr_flat('cutout_center') @property def center_flat(self): """ A `~numpy.ndarray` of the ``(x, y)`` position of all the stars' centers (including linked stars) with respect to the original (large) image (not the cutout image) as a 2D array (``n_all_stars`` x 2). Note that when `EPSFStars` contains any `LinkedEPSFStar`, the ``center`` attribute will be a nested 3D array. """ return self._getattr_flat('center') @lazyproperty def all_stars(self): """ A list of all `EPSFStar` objects stored in this object, including those that comprise linked stars (i.e., `LinkedEPSFStar`), as a flat list. """ stars = [] for item in self._data: if isinstance(item, LinkedEPSFStar): stars.extend(item.all_stars) else: stars.append(item) return stars @property def all_good_stars(self): """ A list of all `EPSFStar` objects stored in this object that have not been excluded from fitting, including those that comprise linked stars (i.e., `LinkedEPSFStar`), as a flat list. """ stars = [] for star in self.all_stars: if star._excluded_from_fit: continue stars.append(star) return stars @lazyproperty def n_stars(self): """ The total number of stars. A linked star is counted only once. """ return len(self._data) @lazyproperty def n_all_stars(self): """ The total number of `EPSFStar` objects, including all the linked stars within `LinkedEPSFStar`. Each linked star is included in the count. """ return len(self.all_stars) @property def n_good_stars(self): """ The total number of `EPSFStar` objects, including all the linked stars within `LinkedEPSFStar`, that have not been excluded from fitting. Each non-excluded linked star is included in the count. """ return len(self.all_good_stars) @lazyproperty def _max_shape(self): """ The maximum x and y shapes of all the `EPSFStar` objects (including linked stars). """ return np.max([star.shape for star in self.all_stars], axis=0) class LinkedEPSFStar(EPSFStars): """ A class to hold a list of `EPSFStar` objects for linked stars. Linked stars are `EPSFStar` cutouts from different images that represent the same physical star. When building the ePSF, linked stars are constrained to have the same sky coordinates. Parameters ---------- stars_list : list of `EPSFStar` objects A list of `EPSFStar` objects for the same physical star. Each `EPSFStar` object must have a valid ``wcs_large`` attribute to convert between pixel and sky coordinates. """ def __init__(self, stars_list): for star in stars_list: if not isinstance(star, EPSFStar): raise TypeError('stars_list must contain only EPSFStar ' 'objects.') if star.wcs_large is None: raise ValueError('Each EPSFStar object must have a valid ' 'wcs_large attribute.') super().__init__(stars_list) def constrain_centers(self): """ Constrain the centers of linked `EPSFStar` objects (i.e., the same physical star) to have the same sky coordinate. Only `EPSFStar` objects that have not been excluded during the ePSF build process will be used to constrain the centers. The single sky coordinate is calculated as the mean of sky coordinates of the linked stars. """ if len(self._data) < 2: # no linked stars return idx = np.logical_not(self._excluded_from_fit).nonzero()[0] if idx.shape == (0,): # pylint: disable=no-member warnings.warn('Cannot constrain centers of linked stars because ' 'all the stars have been excluded during the ePSF ' 'build process.', AstropyUserWarning) return good_stars = [self._data[i] for i in idx] # pylint: disable=not-an-iterable coords = [] for star in good_stars: wcs = star.wcs_large xposition = star.center[0] yposition = star.center[1] coords.append(wcs.pixel_to_world_values(xposition, yposition)) # compute mean cartesian coordinates lon, lat = np.transpose(coords) lon *= np.pi / 180.0 lat *= np.pi / 180.0 x_mean = np.mean(np.cos(lat) * np.cos(lon)) y_mean = np.mean(np.cos(lat) * np.sin(lon)) z_mean = np.mean(np.sin(lat)) # convert mean cartesian coordinates back to spherical hypot = np.hypot(x_mean, y_mean) mean_lon = np.arctan2(y_mean, x_mean) mean_lat = np.arctan2(z_mean, hypot) mean_lon *= 180.0 / np.pi mean_lat *= 180.0 / np.pi # convert mean sky coordinates back to center pixel coordinates # for each star for star in good_stars: center = star.wcs_large.world_to_pixel_values(mean_lon, mean_lat) star.cutout_center = np.array(center) - star.origin def extract_stars(data, catalogs, *, size=(11, 11)): """ Extract cutout images centered on stars defined in the input catalog(s). Stars where the cutout array bounds partially or completely lie outside of the input ``data`` image will not be extracted. Parameters ---------- data : `~astropy.nddata.NDData` or list of `~astropy.nddata.NDData` A `~astropy.nddata.NDData` object or a list of `~astropy.nddata.NDData` objects containing the 2D image(s) from which to extract the stars. If the input ``catalogs`` contain only the sky coordinates (i.e., not the pixel coordinates) of the stars then each of the `~astropy.nddata.NDData` objects must have a valid ``wcs`` attribute. catalogs : `~astropy.table.Table`, list of `~astropy.table.Table` A catalog or list of catalogs of sources to be extracted from the input ``data``. To link stars in multiple images as a single source, you must use a single source catalog where the positions defined in sky coordinates. If a list of catalogs is input (or a single catalog with a single `~astropy.nddata.NDData` object), they are assumed to correspond to the list of `~astropy.nddata.NDData` objects input in ``data`` (i.e., a separate source catalog for each 2D image). For this case, the center of each source can be defined either in pixel coordinates (in ``x`` and ``y`` columns) or sky coordinates (in a ``skycoord`` column containing a `~astropy.coordinates.SkyCoord` object). If both are specified, then the pixel coordinates will be used. If a single source catalog is input with multiple `~astropy.nddata.NDData` objects, then these sources will be extracted from every 2D image in the input ``data``. In this case, the sky coordinates for each source must be specified as a `~astropy.coordinates.SkyCoord` object contained in a column called ``skycoord``. Each `~astropy.nddata.NDData` object in the input ``data`` must also have a valid ``wcs`` attribute. Pixel coordinates (in ``x`` and ``y`` columns) will be ignored. Optionally, each catalog may also contain an ``id`` column representing the ID/name of stars. If this column is not present then the extracted stars will be given an ``id`` number corresponding the table row number (starting at 1). Any other columns present in the input ``catalogs`` will be ignored. size : int or array_like (int), optional The extraction box size along each axis. If ``size`` is a scalar then a square box of size ``size`` will be used. If ``size`` has two elements, they must be in ``(ny, nx)`` order. ``size`` must have odd values and be greater than or equal to 3 for both axes. Returns ------- stars : `EPSFStars` instance A `EPSFStars` instance containing the extracted stars. """ if isinstance(data, NDData): data = [data] if isinstance(catalogs, Table): catalogs = [catalogs] for img in data: if not isinstance(img, NDData): raise TypeError('data must be a single NDData or list of NDData ' 'objects.') for cat in catalogs: if not isinstance(cat, Table): raise TypeError('catalogs must be a single Table or list of Table ' 'objects.') if len(catalogs) == 1 and len(data) > 1: if 'skycoord' not in catalogs[0].colnames: raise ValueError('When inputting a single catalog with multiple ' 'NDData objects, the catalog must have a ' '"skycoord" column.') if any(img.wcs is None for img in data): raise ValueError('When inputting a single catalog with multiple ' 'NDData objects, each NDData object must have ' 'a wcs attribute.') else: for cat in catalogs: if 'x' not in cat.colnames or 'y' not in cat.colnames: if 'skycoord' not in cat.colnames: raise ValueError('When inputting multiple catalogs, ' 'each one must have a "x" and "y" ' 'column or a "skycoord" column.') if any(img.wcs is None for img in data): raise ValueError('When inputting catalog(s) with only ' 'skycoord positions, each NDData object ' 'must have a wcs attribute.') if len(data) != len(catalogs): raise ValueError('When inputting multiple catalogs, the number ' 'of catalogs must match the number of input ' 'images.') size = as_pair('size', size, lower_bound=(3, 0), check_odd=True) if len(catalogs) == 1: # may included linked stars use_xy = True if len(data) > 1: use_xy = False # linked stars require skycoord positions # stars is a list of lists, one list of stars in each image stars = [_extract_stars(img, catalogs[0], size=size, use_xy=use_xy) for img in data] # transpose the list of lists, to associate linked stars stars = list(map(list, zip(*stars, strict=True))) # remove 'None' stars (i.e., no or partial overlap in one or # more images) and handle the case of only one "linked" star stars_out = [] n_input = len(catalogs[0]) * len(data) n_extracted = 0 for star in stars: good_stars = [i for i in star if i is not None] n_extracted += len(good_stars) if not good_stars: continue # no overlap in any image if len(good_stars) == 1: good_stars = good_stars[0] # only one star, cannot be linked else: good_stars = LinkedEPSFStar(good_stars) stars_out.append(good_stars) else: # no linked stars stars_out = [] for img, cat in zip(data, catalogs, strict=True): stars_out.extend(_extract_stars(img, cat, size=size, use_xy=True)) n_input = len(stars_out) stars_out = [star for star in stars_out if star is not None] n_extracted = len(stars_out) n_excluded = n_input - n_extracted if n_excluded > 0: warnings.warn(f'{n_excluded} star(s) were not extracted because ' 'their cutout region extended beyond the input image.', AstropyUserWarning) return EPSFStars(stars_out) def _extract_stars(data, catalog, *, size=(11, 11), use_xy=True): """ Extract cutout images from a single image centered on stars defined in the single input catalog. Parameters ---------- data : `~astropy.nddata.NDData` A `~astropy.nddata.NDData` object containing the 2D image from which to extract the stars. If the input ``catalog`` contains only the sky coordinates (i.e., not the pixel coordinates) of the stars then the `~astropy.nddata.NDData` object must have a valid ``wcs`` attribute. catalog : `~astropy.table.Table` A single catalog of sources to be extracted from the input ``data``. The center of each source can be defined either in pixel coordinates (in ``x`` and ``y`` columns) or sky coordinates (in a ``skycoord`` column containing a `~astropy.coordinates.SkyCoord` object). If both are specified, then the value of the ``use_xy`` keyword determines which coordinates will be used. size : int or array_like (int), optional The extraction box size along each axis. If ``size`` is a scalar then a square box of size ``size`` will be used. If ``size`` has two elements, they must be in ``(ny, nx)`` order. ``size`` must have odd values and be greater than or equal to 3 for both axes. use_xy : bool, optional Whether to use the ``x`` and ``y`` pixel positions when both pixel and sky coordinates are present in the input catalog table. If `False` then sky coordinates are used instead of pixel coordinates (e.g., for linked stars). The default is `True`. Returns ------- stars : list of `EPSFStar` objects A list of `EPSFStar` instances containing the extracted stars. """ size = as_pair('size', size, lower_bound=(3, 0), check_odd=True) colnames = catalog.colnames if ('x' not in colnames or 'y' not in colnames) or not use_xy: xcenters, ycenters = data.wcs.world_to_pixel(catalog['skycoord']) else: xcenters = catalog['x'].data.astype(float) ycenters = catalog['y'].data.astype(float) if 'id' in colnames: ids = catalog['id'] else: ids = np.arange(len(catalog), dtype=int) + 1 if data.uncertainty is None: weights = np.ones_like(data.data) elif data.uncertainty.uncertainty_type == 'weights': weights = np.asanyarray(data.uncertainty.array, dtype=float) else: # other uncertainties are converted to the inverse standard # deviation as the weight; ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) weights = data.uncertainty.represent_as(StdDevUncertainty) weights = 1.0 / weights.array if np.any(~np.isfinite(weights)): warnings.warn('One or more weight values is not finite. Please ' 'check the input uncertainty values in the input ' 'NDData object.', AstropyUserWarning) if data.mask is not None: weights[data.mask] = 0.0 stars = [] for xcenter, ycenter, obj_id in zip(xcenters, ycenters, ids, strict=True): try: large_slc, _ = overlap_slices(data.data.shape, size, (ycenter, xcenter), mode='strict') data_cutout = data.data[large_slc] weights_cutout = weights[large_slc] except (PartialOverlapError, NoOverlapError): stars.append(None) continue origin = (large_slc[1].start, large_slc[0].start) cutout_center = (xcenter - origin[0], ycenter - origin[1]) star = EPSFStar(data_cutout, weights=weights_cutout, cutout_center=cutout_center, origin=origin, wcs_large=data.wcs, id_label=obj_id) stars.append(star) return stars ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/functional_models.py0000644000175100001660000020351314755160622022067 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides mathematical functional PSF models. """ import astropy.units as u import numpy as np from astropy.modeling import Fittable2DModel, Parameter from astropy.modeling.utils import ellipse_extent from astropy.units import UnitsError from astropy.utils.decorators import deprecated from scipy.special import erf, j1, jn_zeros __all__ = [ 'AiryDiskPSF', 'CircularGaussianPRF', 'CircularGaussianPSF', 'CircularGaussianSigmaPRF', 'GaussianPRF', 'GaussianPSF', 'IntegratedGaussianPRF', 'MoffatPSF', ] FLOAT_EPSILON = float(np.finfo(np.float32).tiny) GAUSSIAN_FWHM_TO_SIGMA = 1.0 / (2.0 * np.sqrt(2.0 * np.log(2.0))) def _gaussian_amplitude(flux, xsigma, ysigma): # output units should match the input flux units if isinstance(xsigma, u.Quantity): xsigma = xsigma.value ysigma = ysigma.value return flux / (2.0 * np.pi * xsigma * ysigma) class GaussianPSF(Fittable2DModel): r""" A 2D Gaussian PSF model. This model is evaluated by sampling the 2D Gaussian at the input coordinates. The Gaussian is normalized such that the analytical integral over the entire 2D plane is equal to the total flux. Parameters ---------- flux : float, optional Total integrated flux over the entire PSF. x_0 : float, optional Position of the peak along the x axis. y_0 : float, optional Position of the peak along the y axis. x_fwhm : float, optional The full width at half maximum (FWHM) of the Gaussian along the x axis. y_fwhm : float, optional FWHM of the Gaussian along the y axis. theta : float, optional The counterclockwise rotation angle either as a float (in degrees) or a `~astropy.units.Quantity` angle (optional). bbox_factor : float, optional The multiple of the x and y standard deviations (sigma) used to define the bounding box limits. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` base class. See Also -------- CircularGaussianPSF, GaussianPRF, CircularGaussianPRF, MoffatPSF Notes ----- The Gaussian function is defined as: .. math:: f(x, y) = \frac{F}{2 \pi \sigma_{x} \sigma_{y}} \exp \left( -a\left(x - x_{0}\right)^{2} - b \left(x - x_{0}\right) \left(y - y_{0}\right) - c \left(y - y_{0}\right)^{2} \right) where :math:`F` is the total integrated flux, :math:`(x_{0}, y_{0})` is the position of the peak, and :math:`\sigma_{x}` and :math:`\sigma_{y}` are the standard deviations along the x and y axes, respectively. .. math:: a = \frac{\cos^{2}{\theta}}{2 \sigma_{x}^{2}} + \frac{\sin^{2}{\theta}}{2 \sigma_{y}^{2}} b = \frac{\sin{2 \theta}}{2 \sigma_{x}^{2}} - \frac{\sin{2 \theta}}{2 \sigma_{y}^{2}} c = \frac{\sin^{2}{\theta}} {2 \sigma_{x}^{2}} + \frac{\cos^{2}{\theta}}{2 \sigma_{y}^{2}} where :math:`\theta` is the rotation angle of the Gaussian. The FWHMs of the Gaussian along the x and y axes are given by: .. math:: \rm{FWHM}_{x} = 2 \sigma_{x} \sqrt{2 \ln{2}} \rm{FWHM}_{y} = 2 \sigma_{y} \sqrt{2 \ln{2}} The model is normalized such that: .. math:: \int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F The ``x_fwhm``, ``y_fwhm``, and ``theta`` parameters are fixed by default. If you wish to fit these parameters, set the ``fixed`` attribute to `False`, e.g.,:: >>> from photutils.psf import GaussianPSF >>> model = GaussianPSF() >>> model.x_fwhm.fixed = False >>> model.y_fwhm.fixed = False >>> model.theta.fixed = False By default, the ``x_fwhm`` and ``y_fwhm`` parameters are bounded to be strictly positive. References ---------- .. [1] https://en.wikipedia.org/wiki/Gaussian_function Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.psf import GaussianPSF model = GaussianPSF(flux=71.4, x_0=24.3, y_0=25.2, x_fwhm=10.1, y_fwhm=5.82, theta=21.7) yy, xx = np.mgrid[0:51, 0:51] data = model(xx, yy) plt.imshow(data, origin='lower', interpolation='nearest') """ flux = Parameter( default=1, description='Total integrated flux over the entire PSF.') x_0 = Parameter( default=0, description='Position of the peak along the x axis') y_0 = Parameter( default=0, description='Position of the peak along the y axis') x_fwhm = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='FWHM of the Gaussian along the x axis') y_fwhm = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='FWHM of the Gaussian along the y axis') theta = Parameter( default=0.0, description=('CCW rotation angle either as a float (in ' 'degrees) or a Quantity angle (optional)'), fixed=True) def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, x_fwhm=x_fwhm.default, y_fwhm=y_fwhm.default, theta=theta.default, bbox_factor=5.5, **kwargs): super().__init__(flux=flux, x_0=x_0, y_0=y_0, x_fwhm=x_fwhm, y_fwhm=y_fwhm, theta=theta, **kwargs) self.bbox_factor = bbox_factor @property def amplitude(self): """ The peak amplitude of the Gaussian. """ return _gaussian_amplitude(self.flux, self.x_sigma, self.y_sigma) @property def x_sigma(self): """ Gaussian sigma (standard deviation) along the x axis. """ return self.x_fwhm * GAUSSIAN_FWHM_TO_SIGMA @property def y_sigma(self): """ Gaussian sigma (standard deviation) along the y axis. """ return self.y_fwhm * GAUSSIAN_FWHM_TO_SIGMA def _calc_bounding_box(self, factor=5.5): """ Calculate a bounding box defining the limits of the model. The limits are adjusted for rotation. Parameters ---------- factor : float, optional The multiple of the x and y standard deviations (sigma) used to define the limits. Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ a = factor * self.x_sigma b = factor * self.y_sigma dx, dy = ellipse_extent(a, b, self.theta) return ((self.y_0 - dy, self.y_0 + dy), (self.x_0 - dx, self.x_0 + dx)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from photutils.psf import GaussianPSF >>> model = GaussianPSF(x_0=0, y_0=0, x_fwhm=2, y_fwhm=3) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-4.671269901584105, upper=4.671269901584105) y: Interval(lower=-7.006904852376157, upper=7.006904852376157) } model=GaussianPSF(inputs=('x', 'y')) order='C' ) >>> model.bbox_factor = 7 >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-5.945252602016134, upper=5.945252602016134) y: Interval(lower=-8.9178789030242, upper=8.9178789030242) } model=GaussianPSF(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box(factor=self.bbox_factor) def evaluate(self, x, y, flux, x_0, y_0, x_fwhm, y_fwhm, theta): """ Calculate the value of the 2D Gaussian model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float Total integrated flux over the entire PSF. x_0, y_0 : float Position of the peak along the x and y axes. x_fwhm, y_fwhm : float FWHM of the Gaussian along the x and y axes. theta : float The counterclockwise rotation angle either as a float (in degrees) or a `~astropy.units.Quantity` angle (optional). Returns ------- result : `~numpy.ndarray` The value of the model evaluated at the input coordinates. """ if not isinstance(theta, u.Quantity): theta = np.deg2rad(theta) cost2 = np.cos(theta) ** 2 sint2 = np.sin(theta) ** 2 sin2t = np.sin(2.0 * theta) xstd = x_fwhm * GAUSSIAN_FWHM_TO_SIGMA ystd = y_fwhm * GAUSSIAN_FWHM_TO_SIGMA xstd2 = xstd ** 2 ystd2 = ystd ** 2 xdiff = x - x_0 ydiff = y - y_0 a = 0.5 * ((cost2 / xstd2) + (sint2 / ystd2)) b = 0.5 * ((sin2t / xstd2) - (sin2t / ystd2)) c = 0.5 * ((sint2 / xstd2) + (cost2 / ystd2)) # output units should match the input flux units if isinstance(xstd, u.Quantity): xstd = xstd.value ystd = ystd.value amplitude = flux / (2 * np.pi * xstd * ystd) return amplitude * np.exp( -(a * xdiff**2) - (b * xdiff * ydiff) - (c * ydiff**2)) @staticmethod def fit_deriv(x, y, flux, x_0, y_0, x_fwhm, y_fwhm, theta): """ Calculate the partial derivatives of the 2D Gaussian function with respect to the parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float Total integrated flux over the entire PSF. x_0, y_0 : float Position of the peak along the x and y axes. x_fwhm, y_fwhm : float FWHM of the Gaussian along the x and y axes. theta : float The counterclockwise rotation angle either as a float (in degrees) or a `~astropy.units.Quantity` angle (optional). Returns ------- result : list of `~numpy.ndarray` The list of partial derivatives with respect to each parameter. """ if not isinstance(theta, u.Quantity): theta = np.deg2rad(theta) cost = np.cos(theta) sint = np.sin(theta) cost2 = cost ** 2 sint2 = sint ** 2 cos2t = np.cos(2.0 * theta) sin2t = np.sin(2.0 * theta) xstd = x_fwhm * GAUSSIAN_FWHM_TO_SIGMA ystd = y_fwhm * GAUSSIAN_FWHM_TO_SIGMA xstd2 = xstd ** 2 ystd2 = ystd ** 2 xstd3 = xstd ** 3 ystd3 = ystd ** 3 xdiff = x - x_0 ydiff = y - y_0 xdiff2 = xdiff ** 2 ydiff2 = ydiff ** 2 a = 0.5 * ((cost2 / xstd2) + (sint2 / ystd2)) b = 0.5 * ((sin2t / xstd2) - (sin2t / ystd2)) c = 0.5 * ((sint2 / xstd2) + (cost2 / ystd2)) amplitude = flux / (2 * np.pi * xstd * ystd) exp = np.exp(-(a * xdiff2) - (b * xdiff * ydiff) - (c * ydiff2)) g = amplitude * exp da_dtheta = sint * cost * ((1.0 / ystd2) - (1.0 / xstd2)) db_dtheta = (cos2t / xstd2) - (cos2t / ystd2) dc_dtheta = -da_dtheta da_dxstd = -cost2 / xstd3 db_dxstd = -sin2t / xstd3 dc_dxstd = -sint2 / xstd3 da_dystd = -sint2 / ystd3 db_dystd = sin2t / ystd3 dc_dystd = -cost2 / ystd3 dg_dflux = g / flux dg_dx_0 = g * ((2.0 * a * xdiff) + (b * ydiff)) dg_dy_0 = g * ((b * xdiff) + (2.0 * c * ydiff)) damp_dxstd = -amplitude / xstd damp_dystd = -amplitude / ystd dexp_dxstd = -exp * (da_dxstd * xdiff2 + db_dxstd * xdiff * ydiff + dc_dxstd * ydiff2) dexp_dystd = -exp * (da_dystd * xdiff2 + db_dystd * xdiff * ydiff + dc_dystd * ydiff2) dg_dxstd = damp_dxstd * exp + amplitude * dexp_dxstd dg_dystd = damp_dystd * exp + amplitude * dexp_dystd # chain rule for change of variables from sigma to fwhm # std => fwhm * GAUSSIAN_FWHM_TO_SIGMA # dstd/dfwhm => GAUSSIAN_FWHM_TO_SIGMA dg_dxfwhm = dg_dxstd * GAUSSIAN_FWHM_TO_SIGMA dg_dyfwhm = dg_dystd * GAUSSIAN_FWHM_TO_SIGMA dg_dtheta = g * (-(da_dtheta * xdiff2 + db_dtheta * xdiff * ydiff + dc_dtheta * ydiff2)) # chain rule for unit change; # theta[rad] => theta[deg] * pi / 180; drad/dtheta = pi / 180 dg_dtheta *= np.pi / 180.0 return [dg_dflux, dg_dx_0, dg_dy_0, dg_dxfwhm, dg_dyfwhm, dg_dtheta] @property def input_units(self): """ The input units of the model. """ x_unit = self.x_0.input_unit y_unit = self.y_0.input_unit if x_unit is None and y_unit is None: return None return {self.inputs[0]: x_unit, self.inputs[1]: y_unit} def _parameter_units_for_data_units(self, inputs_unit, outputs_unit): # Note that here we need to make sure that x and y are in the same # units otherwise this can lead to issues since rotation is not well # defined. if inputs_unit[self.inputs[0]] != inputs_unit[self.inputs[1]]: raise UnitsError("Units of 'x' and 'y' inputs should match") return {'x_0': inputs_unit[self.inputs[0]], 'y_0': inputs_unit[self.inputs[0]], 'x_fwhm': inputs_unit[self.inputs[0]], 'y_fwhm': inputs_unit[self.inputs[0]], 'theta': u.deg, 'flux': outputs_unit[self.outputs[0]]} class CircularGaussianPSF(Fittable2DModel): r""" A circular 2D Gaussian PSF model. This model is evaluated by sampling the 2D Gaussian at the input coordinates. The Gaussian is normalized such that the analytical integral over the entire 2D plane is equal to the total flux. Parameters ---------- flux : float, optional Total integrated flux over the entire PSF. x_0 : float, optional Position of the peak along the x axis. y_0 : float, optional Position of the peak along the y axis. fwhm : float, optional The full width at half maximum (FWHM) of the Gaussian. bbox_factor : float, optional The multiple of the standard deviation (sigma) used to define the bounding box limits. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` base class. See Also -------- GaussianPSF, GaussianPRF, CircularGaussianPRF, MoffatPSF Notes ----- The circular Gaussian function is defined as: .. math:: f(x, y) = \frac{F}{2 \pi \sigma^{2}} \exp \left( {\frac{-(x - x_{0})^{2} - (y - y_{0})^{2}} {2 \sigma^{2}}} \right) where :math:`F` is the total integrated flux, :math:`(x_{0}, y_{0})` is the position of the peak, and :math:`\sigma` is the standard deviation, respectively. The FWHM of the Gaussian is given by: .. math:: \rm{FWHM} = 2 \sigma \sqrt{2 \ln{2}} The model is normalized such that: .. math:: \int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F The ``fwhm`` parameter is fixed by default. If you wish to fit this parameter, set the ``fixed`` attribute to `False`, e.g.,:: >>> from photutils.psf import CircularGaussianPSF >>> model = CircularGaussianPSF() >>> model.fwhm.fixed = False By default, the ``fwhm`` parameter is bounded to be strictly positive. References ---------- .. [1] https://en.wikipedia.org/wiki/Gaussian_function Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.psf import CircularGaussianPSF model = CircularGaussianPSF(flux=71.4, x_0=24.3, y_0=25.2, fwhm=10.1) yy, xx = np.mgrid[0:51, 0:51] data = model(xx, yy) plt.imshow(data, origin='lower', interpolation='nearest') """ flux = Parameter( default=1, description='Total integrated flux over the entire PSF.') x_0 = Parameter( default=0, description='Position of the peak along the x axis') y_0 = Parameter( default=0, description='Position of the peak along the y axis') fwhm = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='FWHM of the Gaussian') def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, fwhm=fwhm.default, bbox_factor=5.5, **kwargs): super().__init__(flux=flux, x_0=x_0, y_0=y_0, fwhm=fwhm, **kwargs) self.bbox_factor = bbox_factor @property def amplitude(self): """ The peak amplitude of the Gaussian. """ return _gaussian_amplitude(self.flux, self.sigma, self.sigma) @property def sigma(self): """ Gaussian sigma (standard deviation). """ return self.fwhm * GAUSSIAN_FWHM_TO_SIGMA def _calc_bounding_box(self, factor=5.5): """ Calculate a bounding box defining the limits of the model. Parameters ---------- factor : float, optional The multiple of the standard deviations (sigma) used to define the limits. Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ delta = factor * self.sigma return ((self.y_0 - delta, self.y_0 + delta), (self.x_0 - delta, self.x_0 + delta)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from photutils.psf import CircularGaussianPSF >>> model = CircularGaussianPSF(x_0=0, y_0=0, fwhm=2) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-4.671269901584105, upper=4.671269901584105) y: Interval(lower=-4.671269901584105, upper=4.671269901584105) } model=CircularGaussianPSF(inputs=('x', 'y')) order='C' ) >>> model.bbox_factor = 7 >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-5.945252602016134, upper=5.945252602016134) y: Interval(lower=-5.945252602016134, upper=5.945252602016134) } model=CircularGaussianPSF(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box(factor=self.bbox_factor) def evaluate(self, x, y, flux, x_0, y_0, fwhm): """ Calculate the value of the 2D Gaussian model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float Total integrated flux over the entire PSF. x_0, y_0 : float Position of the peak along the x and y axes. fwhm : float FWHM of the Gaussian. Returns ------- result : `~numpy.ndarray` The value of the model evaluated at the input coordinates. """ sigma2 = (fwhm * GAUSSIAN_FWHM_TO_SIGMA) ** 2 # output units should match the input flux units sigma2_norm = sigma2 if isinstance(sigma2, u.Quantity): sigma2_norm = sigma2.value amplitude = flux / (2 * np.pi * sigma2_norm) return amplitude * np.exp(-0.5 * ((x - x_0) ** 2 + (y - y_0) ** 2) / sigma2) @staticmethod def fit_deriv(x, y, flux, x_0, y_0, fwhm): """ Calculate the partial derivatives of the 2D Gaussian function with respect to the parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float Total integrated flux over the entire PSF. x_0, y_0 : float Position of the peak along the x and y axes. fwhm : float FWHM of the Gaussian. Returns ------- result : list of `~numpy.ndarray` The list of partial derivatives with respect to each parameter. """ return GaussianPSF().fit_deriv(x, y, flux, x_0, y_0, fwhm, fwhm, 0.0)[:-2] @property def input_units(self): """ The input units of the model. """ x_unit = self.x_0.input_unit y_unit = self.y_0.input_unit if x_unit is None and y_unit is None: return None return {self.inputs[0]: x_unit, self.inputs[1]: y_unit} def _parameter_units_for_data_units(self, inputs_unit, outputs_unit): return {'x_0': inputs_unit[self.inputs[0]], 'y_0': inputs_unit[self.inputs[0]], 'fwhm': inputs_unit[self.inputs[0]], 'flux': outputs_unit[self.outputs[0]]} class GaussianPRF(Fittable2DModel): r""" A 2D Gaussian PSF model integrated over pixels. This model is evaluated by integrating the 2D Gaussian over the input coordinate pixels, and is equivalent to assuming the PSF is 2D Gaussian at a *sub-pixel* level. Because it is integrated over pixels, this model is considered a PRF instead of a PSF. The Gaussian is normalized such that the analytical integral over the entire 2D plane is equal to the total flux. Parameters ---------- flux : float, optional Total integrated flux over the entire PSF. x_0 : float, optional Position of the peak along the x axis. y_0 : float, optional Position of the peak along the y axis. x_fwhm : float, optional The full width at half maximum (FWHM) of the Gaussian along the x axis. y_fwhm : float, optional FWHM of the Gaussian along the y axis. theta : float, optional The counterclockwise rotation angle either as a float (in degrees) or a `~astropy.units.Quantity` angle (optional). bbox_factor : float, optional The multiple of the x and y standard deviations (sigma) used to define the bounding_box limits. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` base class. See Also -------- GaussianPSF, CircularGaussianPSF, CircularGaussianPRF, MoffatPSF Notes ----- The Gaussian function is defined as: .. math:: f(x, y) = \frac{F}{4} \left[ {\rm erf} \left( \frac{x^\prime + 0.5}{\sqrt{2} \sigma_{x}} \right) - {\rm erf} \left( \frac{x^\prime - 0.5}{\sqrt{2} \sigma_{x}} \right) \right] \left[ {\rm erf} \left( \frac{y^\prime + 0.5}{\sqrt{2} \sigma_{y}} \right) - {\rm erf} \left( \frac{y^\prime - 0.5}{\sqrt{2} \sigma_{y}} \right) \right] where :math:`F` is the total integrated flux, :math:`\sigma_{x}` and :math:`\sigma_{y}` are the standard deviations along the x and y axes, respectively, and :math:`{\rm erf}` denotes the error function. .. math:: x^\prime = (x - x_0) \cos(\theta) + (y - y_0) \sin(\theta) y^\prime = -(x - x_0) \sin(\theta) + (y - y_0) \cos(\theta) where :math:`(x_{0}, y_{0})` is the position of the peak and :math:`\theta` is the rotation angle of the Gaussian. The FWHMs of the Gaussian along the x and y axes are given by: .. math:: \rm{FWHM}_{x} = 2 \sigma_{x} \sqrt{2 \ln{2}} \rm{FWHM}_{y} = 2 \sigma_{y} \sqrt{2 \ln{2}} The model is normalized such that: .. math:: \int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F The ``x_fwhm``, ``y_fwhm``, and ``theta`` parameters are fixed by default. If you wish to fit these parameters, set the ``fixed`` attribute to `False`, e.g.,:: >>> from photutils.psf import GaussianPRF >>> model = GaussianPRF() >>> model.x_fwhm.fixed = False >>> model.y_fwhm.fixed = False >>> model.theta.fixed = False By default, the ``x_fwhm`` and ``y_fwhm`` parameters are bounded to be strictly positive. References ---------- .. [1] https://en.wikipedia.org/wiki/Gaussian_function Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.psf import GaussianPRF model = GaussianPRF(flux=71.4, x_0=24.3, y_0=25.2, x_fwhm=10.1, y_fwhm=5.82, theta=21.7) yy, xx = np.mgrid[0:51, 0:51] data = model(xx, yy) plt.imshow(data, origin='lower', interpolation='nearest') """ flux = Parameter( default=1, description='Total integrated flux over the entire PSF.') x_0 = Parameter( default=0, description='Position of the peak along the x axis') y_0 = Parameter( default=0, description='Position of the peak along the y axis') x_fwhm = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='FWHM of the Gaussian along the x axis') y_fwhm = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='FWHM of the Gaussian along the y axis') theta = Parameter( default=0.0, description=('CCW rotation angle either as a float (in ' 'degrees) or a Quantity angle (optional)'), fixed=True) def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, x_fwhm=x_fwhm.default, y_fwhm=y_fwhm.default, theta=theta.default, bbox_factor=5.5, **kwargs): super().__init__(flux=flux, x_0=x_0, y_0=y_0, x_fwhm=x_fwhm, y_fwhm=y_fwhm, theta=theta, **kwargs) self.bbox_factor = bbox_factor @property def amplitude(self): """ The peak amplitude of the Gaussian. """ return _gaussian_amplitude(self.flux, self.x_sigma, self.y_sigma) @property def x_sigma(self): """ Gaussian sigma (standard deviation) along the x axis. """ return self.x_fwhm * GAUSSIAN_FWHM_TO_SIGMA @property def y_sigma(self): """ Gaussian sigma (standard deviation) along the y axis. """ return self.y_fwhm * GAUSSIAN_FWHM_TO_SIGMA def _calc_bounding_box(self, factor=5.5): """ Calculate a bounding box defining the limits of the model. The limits are adjusted for rotation. Parameters ---------- factor : float, optional The multiple of the x and y FWHMs used to define the limits. zzzz Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ a = factor * self.x_sigma b = factor * self.y_sigma dx, dy = ellipse_extent(a, b, self.theta) return ((self.y_0 - dy, self.y_0 + dy), (self.x_0 - dx, self.x_0 + dx)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from photutils.psf import GaussianPRF >>> model = GaussianPRF(x_0=0, y_0=0, x_fwhm=2, y_fwhm=3) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-4.671269901584105, upper=4.671269901584105) y: Interval(lower=-7.006904852376157, upper=7.006904852376157) } model=GaussianPRF(inputs=('x', 'y')) order='C' ) >>> model.bbox_factor = 7 >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-5.945252602016134, upper=5.945252602016134) y: Interval(lower=-8.9178789030242, upper=8.9178789030242) } model=GaussianPRF(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box(factor=self.bbox_factor) def evaluate(self, x, y, flux, x_0, y_0, x_fwhm, y_fwhm, theta): """ Calculate the value of the 2D Gaussian model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float Total integrated flux over the entire PSF. x_0, y_0 : float Position of the peak along the x and y axes. x_fwhm, y_fwhm : float FWHM of the Gaussian along the x and y axes. theta : float The counterclockwise rotation angle either as a float (in degrees) or a `~astropy.units.Quantity` angle (optional). Returns ------- result : `~numpy.ndarray` The value of the model evaluated at the input coordinates. """ if not isinstance(theta, u.Quantity): theta = np.deg2rad(theta) x_sigma = x_fwhm * GAUSSIAN_FWHM_TO_SIGMA y_sigma = y_fwhm * GAUSSIAN_FWHM_TO_SIGMA dx = x - x_0 dy = y - y_0 cost = np.cos(theta) sint = np.sin(theta) x0 = dx * cost + dy * sint y0 = -dx * sint + dy * cost dpix = 0.5 if isinstance(x0, u.Quantity): dpix <<= x0.unit return (flux / 4.0 * ((erf((x0 + dpix) / (np.sqrt(2) * x_sigma)) - erf((x0 - dpix) / (np.sqrt(2) * x_sigma))) * (erf((y0 + dpix) / (np.sqrt(2) * y_sigma)) - erf((y0 - dpix) / (np.sqrt(2) * y_sigma))))) @property def input_units(self): """ The input units of the model. """ x_unit = self.x_0.input_unit y_unit = self.y_0.input_unit if x_unit is None and y_unit is None: return None return {self.inputs[0]: x_unit, self.inputs[1]: y_unit} def _parameter_units_for_data_units(self, inputs_unit, outputs_unit): # Note that here we need to make sure that x and y are in the same # units otherwise this can lead to issues since rotation is not well # defined. if inputs_unit[self.inputs[0]] != inputs_unit[self.inputs[1]]: raise UnitsError("Units of 'x' and 'y' inputs should match") return {'x_0': inputs_unit[self.inputs[0]], 'y_0': inputs_unit[self.inputs[0]], 'x_fwhm': inputs_unit[self.inputs[0]], 'y_fwhm': inputs_unit[self.inputs[0]], 'theta': u.deg, 'flux': outputs_unit[self.outputs[0]]} class CircularGaussianPRF(Fittable2DModel): r""" A circular 2D Gaussian PSF model integrated over pixels. This model is evaluated by integrating the 2D Gaussian over the input coordinate pixels, and is equivalent to assuming the PSF is 2D Gaussian at a *sub-pixel* level. Because it is integrated over pixels, this model is considered a PRF instead of a PSF. The Gaussian is normalized such that the analytical integral over the entire 2D plane is equal to the total flux. Parameters ---------- flux : float, optional Total integrated flux over the entire PSF. x_0 : float, optional Position of the peak along the x axis. y_0 : float, optional Position of the peak along the y axis. fwhm : float, optional The full width at half maximum (FWHM) of the Gaussian. bbox_factor : float, optional The multiple of the standard deviation (sigma) used to define the bounding box limits. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` base class. See Also -------- GaussianPRF, GaussianPSF, CircularGaussianPSF, MoffatPSF Notes ----- The circular Gaussian function is defined as: .. math:: f(x, y) = \frac{F}{4} \left[ {\rm erf} \left( \frac{x - x_0 + 0.5}{\sqrt{2} \sigma} \right) - {\rm erf} \left( \frac{x - x_0 - 0.5}{\sqrt{2} \sigma} \right) \right] \left[ {\rm erf} \left( \frac{y - y_0 + 0.5}{\sqrt{2} \sigma} \right) - {\rm erf} \left( \frac{y - y_0 - 0.5}{\sqrt{2} \sigma} \right) \right] where :math:`F` is the total integrated flux, :math:`(x_{0}, y_{0})` is the position of the peak, :math:`\sigma` is the standard deviation of the Gaussian, and :math:`{\rm erf}` denotes the error function. The FWHM of the Gaussian is given by: .. math:: \rm{FWHM} = 2 \sigma \sqrt{2 \ln{2}} The model is normalized such that: .. math:: \int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F The ``fwhm`` parameter is fixed by default. If you wish to fit this parameter, set the ``fixed`` attribute to `False`, e.g.,:: >>> from photutils.psf import CircularGaussianPRF >>> model = CircularGaussianPRF() >>> model.fwhm.fixed = False By default, the ``fwhm`` parameter is bounded to be strictly positive. References ---------- .. [1] https://en.wikipedia.org/wiki/Gaussian_function Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.psf import CircularGaussianPRF model = CircularGaussianPRF(flux=71.4, x_0=24.3, y_0=25.2, fwhm=10.1) yy, xx = np.mgrid[0:51, 0:51] data = model(xx, yy) plt.imshow(data, origin='lower', interpolation='nearest') """ flux = Parameter( default=1, description='Total integrated flux over the entire PSF.') x_0 = Parameter( default=0, description='Position of the peak along the x axis') y_0 = Parameter( default=0, description='Position of the peak along the y axis') fwhm = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='FWHM of the Gaussian') def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, fwhm=fwhm.default, bbox_factor=5.5, **kwargs): super().__init__(flux=flux, x_0=x_0, y_0=y_0, fwhm=fwhm, **kwargs) self.bbox_factor = bbox_factor @property def amplitude(self): """ The peak amplitude of the Gaussian. """ return _gaussian_amplitude(self.flux, self.sigma, self.sigma) @property def sigma(self): """ Gaussian sigma (standard deviation). """ return self.fwhm * GAUSSIAN_FWHM_TO_SIGMA def _calc_bounding_box(self, factor=5.5): """ Calculate a bounding box defining the limits of the model. Parameters ---------- factor : float, optional The multiple of the standard deviations (sigma) used to define the limits. Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ delta = factor * self.sigma return ((self.y_0 - delta, self.y_0 + delta), (self.x_0 - delta, self.x_0 + delta)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from photutils.psf import CircularGaussianPRF >>> model = CircularGaussianPRF(x_0=0, y_0=0, fwhm=2) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-4.671269901584105, upper=4.671269901584105) y: Interval(lower=-4.671269901584105, upper=4.671269901584105) } model=CircularGaussianPRF(inputs=('x', 'y')) order='C' ) >>> model.bbox_factor = 7 >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-5.945252602016134, upper=5.945252602016134) y: Interval(lower=-5.945252602016134, upper=5.945252602016134) } model=CircularGaussianPRF(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box(factor=self.bbox_factor) def evaluate(self, x, y, flux, x_0, y_0, fwhm): """ Calculate the value of the 2D Gaussian model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float Total integrated flux over the entire PSF. x_0, y_0 : float Position of the peak along the x and y axes. fwhm : float FWHM of the Gaussian. Returns ------- result : `~numpy.ndarray` The value of the model evaluated at the input coordinates. """ x0 = x - x_0 y0 = y - y_0 sigma = fwhm * GAUSSIAN_FWHM_TO_SIGMA dpix = 0.5 if isinstance(x0, u.Quantity): dpix <<= x0.unit return (flux / 4.0 * ((erf((x0 + dpix) / (np.sqrt(2) * sigma)) - erf((x0 - dpix) / (np.sqrt(2) * sigma))) * (erf((y0 + dpix) / (np.sqrt(2) * sigma)) - erf((y0 - dpix) / (np.sqrt(2) * sigma))))) @property def input_units(self): """ The input units of the model. """ x_unit = self.x_0.input_unit y_unit = self.y_0.input_unit if x_unit is None and y_unit is None: return None return {self.inputs[0]: x_unit, self.inputs[1]: y_unit} def _parameter_units_for_data_units(self, inputs_unit, outputs_unit): return {'x_0': inputs_unit[self.inputs[0]], 'y_0': inputs_unit[self.inputs[0]], 'fwhm': inputs_unit[self.inputs[0]], 'flux': outputs_unit[self.outputs[0]]} class CircularGaussianSigmaPRF(Fittable2DModel): r""" A circular 2D Gaussian PSF model integrated over pixels. This model is evaluated by integrating the 2D Gaussian over the input coordinate pixels, and is equivalent to assuming the PSF is 2D Gaussian at a *sub-pixel* level. Because it is integrated over pixels, this model is considered a PRF instead of a PSF. The Gaussian is normalized such that the analytical integral over the entire 2D plane is equal to the total flux. This model is equivalent to `CircularGaussianPRF`, but it is parameterized in terms of the standard deviation (sigma) instead of the full width at half maximum (FWHM). Parameters ---------- flux : float, optional Total integrated flux over the entire PSF. x_0 : float, optional Position of the peak in x direction. y_0 : float, optional Position of the peak in y direction. sigma : float, optional Width of the Gaussian PSF. bbox_factor : float, optional The multiple of the standard deviation (sigma) used to define the bounding box limits. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` parent class. See Also -------- GaussianPSF, GaussianPRF, CircularGaussianPSF, CircularGaussianPRF Notes ----- The circular Gaussian function is defined as: .. math:: f(x, y) = \frac{F}{4} \left[ {\rm erf} \left(\frac{x - x_0 + 0.5} {\sqrt{2} \sigma} \right) - {\rm erf} \left(\frac{x - x_0 - 0.5} {\sqrt{2} \sigma} \right) \right] \left[ {\rm erf} \left(\frac{y - y_0 + 0.5} {\sqrt{2} \sigma} \right) - {\rm erf} \left(\frac{y - y_0 - 0.5} {\sqrt{2} \sigma} \right) \right] where :math:`F` is the total integrated flux, :math:`(x_{0}, y_{0})` is the position of the peak, :math:`\sigma` is the standard deviation of the Gaussian, and :math:`{\rm erf}` denotes the error function. The model is normalized such that: .. math:: \int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F The ``sigma`` parameter is fixed by default. If you wish to fit this parameter, set the ``fixed`` attribute to `False`, e.g.,:: >>> from photutils.psf import CircularGaussianSigmaPRF >>> model = CircularGaussianSigmaPRF() >>> model.sigma.fixed = False By default, the ``sigma`` parameter is bounded to be strictly positive. References ---------- .. [1] https://en.wikipedia.org/wiki/Gaussian_function Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.psf import CircularGaussianSigmaPRF model = CircularGaussianSigmaPRF(flux=71.4, x_0=24.3, y_0=25.2, sigma=5.1) yy, xx = np.mgrid[0:51, 0:51] data = model(xx, yy) plt.imshow(data, origin='lower', interpolation='nearest') """ flux = Parameter( default=1, description='Total integrated flux over the entire PSF.') x_0 = Parameter( default=0, description='Position of the peak along the x axis') y_0 = Parameter( default=0, description='Position of the peak along the y axis') sigma = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='Sigma (standard deviation) of the Gaussian') def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, sigma=sigma.default, bbox_factor=5.5, **kwargs): super().__init__(sigma=sigma, x_0=x_0, y_0=y_0, flux=flux, **kwargs) self.bbox_factor = bbox_factor @property def amplitude(self): """ The peak amplitude of the Gaussian. """ return _gaussian_amplitude(self.flux, self.sigma, self.sigma) @property def fwhm(self): """ Gaussian FWHM. """ return self.sigma / GAUSSIAN_FWHM_TO_SIGMA def _calc_bounding_box(self, factor=5.5): """ Calculate a bounding box defining the limits of the model. Parameters ---------- factor : float, optional The multiple of the standard deviations (sigma) used to define the limits. Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ delta = factor * self.sigma return ((self.y_0 - delta, self.y_0 + delta), (self.x_0 - delta, self.x_0 + delta)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from photutils.psf import CircularGaussianPRF >>> model = CircularGaussianPRF(x_0=0, y_0=0, fwhm=2) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-4.671269901584105, upper=4.671269901584105) y: Interval(lower=-4.671269901584105, upper=4.671269901584105) } model=CircularGaussianPRF(inputs=('x', 'y')) order='C' ) >>> model.bbox_factor = 7 >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-5.945252602016134, upper=5.945252602016134) y: Interval(lower=-5.945252602016134, upper=5.945252602016134) } model=CircularGaussianPRF(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box(factor=self.bbox_factor) def evaluate(self, x, y, flux, x_0, y_0, sigma): """ Calculate the value of the 2D Gaussian model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or array_like The coordinates at which to evaluate the model. flux : float The total flux of the star. x_0, y_0 : float The position of the star. sigma : float The width of the Gaussian PRF. Returns ------- evaluated_model : `~numpy.ndarray` The evaluated model. """ dpix = 0.5 if isinstance(x_0, u.Quantity): dpix *= x_0.unit return (flux / 4 * ((erf((x - x_0 + dpix) / (np.sqrt(2) * sigma)) - erf((x - x_0 - dpix) / (np.sqrt(2) * sigma))) * (erf((y - y_0 + dpix) / (np.sqrt(2) * sigma)) - erf((y - y_0 - dpix) / (np.sqrt(2) * sigma))))) @property def input_units(self): """ The input units of the model. """ x_unit = self.x_0.input_unit y_unit = self.y_0.input_unit if x_unit is None and y_unit is None: return None return {self.inputs[0]: x_unit, self.inputs[1]: y_unit} def _parameter_units_for_data_units(self, inputs_unit, outputs_unit): # Note that here we need to make sure that x and y are in the same # units otherwise this can lead to issues since rotation is not well # defined. if inputs_unit[self.inputs[0]] != inputs_unit[self.inputs[1]]: raise UnitsError("Units of 'x' and 'y' inputs should match") 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]]} @deprecated('2.0.0', alternative='`CircularGaussianSigmaPRF` or ' '`CircularGaussianPRF`') class IntegratedGaussianPRF(CircularGaussianSigmaPRF): r""" A circular 2D Gaussian PSF model integrated over pixels. This model is evaluated by integrating the 2D Gaussian over the input coordinate pixels, and is equivalent to assuming the PSF is 2D Gaussian at a *sub-pixel* level. Because it is integrated over pixels, this model is considered a PRF instead of a PSF. The Gaussian is normalized such that the analytical integral over the entire 2D plane is equal to the total flux. This model is equivalent to `CircularGaussianPRF`, but it is parameterized in terms of the standard deviation (sigma) instead of the full width at half maximum (FWHM). Parameters ---------- flux : float, optional Total integrated flux over the entire PSF. x_0 : float, optional Position of the peak in x direction. y_0 : float, optional Position of the peak in y direction. sigma : float, optional Width of the Gaussian PSF. bbox_factor : float, optional The multiple of the standard deviation (sigma) used to define the bounding box limits. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` parent class. See Also -------- GaussianPSF, GaussianPRF, CircularGaussianPSF, CircularGaussianPRF Notes ----- The circular Gaussian function is defined as: .. math:: f(x, y) = \frac{F}{4} \left[ {\rm erf} \left(\frac{x - x_0 + 0.5} {\sqrt{2} \sigma} \right) - {\rm erf} \left(\frac{x - x_0 - 0.5} {\sqrt{2} \sigma} \right) \right] \left[ {\rm erf} \left(\frac{y - y_0 + 0.5} {\sqrt{2} \sigma} \right) - {\rm erf} \left(\frac{y - y_0 - 0.5} {\sqrt{2} \sigma} \right) \right] where :math:`F` is the total integrated flux, :math:`(x_{0}, y_{0})` is the position of the peak, :math:`\sigma` is the standard deviation of the Gaussian, and :math:`{\rm erf}` denotes the error function. The model is normalized such that: .. math:: \int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F References ---------- .. [1] https://en.wikipedia.org/wiki/Gaussian_function """ flux = Parameter( default=1, description='Total integrated flux over the entire PSF.') x_0 = Parameter( default=0, description='Position of the peak along the x axis') y_0 = Parameter( default=0, description='Position of the peak along the y axis') sigma = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='Sigma (standard deviation) of the Gaussian') def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, sigma=sigma.default, bbox_factor=5.5, **kwargs): super().__init__(sigma=sigma, x_0=x_0, y_0=y_0, flux=flux, bbox_factor=bbox_factor, **kwargs) class MoffatPSF(Fittable2DModel): r""" A 2D Moffat PSF model. This model is evaluated by sampling the 2D Moffat function at the input coordinates. The Moffat profile is normalized such that the analytical integral over the entire 2D plane is equal to the total flux. Parameters ---------- flux : float, optional Total integrated flux over the entire PSF. x_0 : float, optional Position of the peak along the x axis. y_0 : float, optional Position of the peak along the y axis. alpha : float, optional The characteristic radius of the Moffat profile. beta : float, optional The asymptotic power-law slope of the Moffat profile wings at large radial distances. Larger values provide less flux in the profile wings. For large ``beta``, this profile approaches a Gaussian profile. ``beta`` must be greater than 1. If ``beta`` is set to 1, then the Moffat profile is a Lorentz function, whose integral is infinite. For this normalized model, if ``beta`` is set to 1, then the profile will be zero everywhere. bbox_factor : float, optional The multiple of the FWHM used to define the bounding box limits. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` base class. See Also -------- GaussianPSF, CircularGaussianPSF, GaussianPRF, CircularGaussianPRF Notes ----- The Moffat profile is defined as: .. math:: f(x, y) = F \frac{\beta - 1}{\pi \alpha^2} \left(1 + \frac{\left(x - x_{0}\right)^{2} + \left(y - y_{0}\right)^{2}}{\alpha^{2}}\right)^{-\beta} where :math:`F` is the total integrated flux and :math:`(x_{0}, y_{0})` is the position of the peak. Note that :math:`\beta` must be greater than 1. The FWHM of the Moffat profile is given by: .. math:: \rm{FWHM} = 2 \alpha \sqrt{2^{1 / \beta} - 1} The model is normalized such that, for :math:`\beta > 1`: .. math:: \int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F The ``alpha`` and ``beta`` parameters are fixed by default. If you wish to fit these parameters, set the ``fixed`` attribute to `False`, e.g.,:: >>> from photutils.psf import MoffatPSF >>> model = MoffatPSF() >>> model.alpha.fixed = False >>> model.beta.fixed = False By default, the ``alpha`` parameter is bounded to be strictly positive and the ``beta`` parameter is bounded to be greater than 1. References ---------- .. [1] https://en.wikipedia.org/wiki/Moffat_distribution .. [2] https://ui.adsabs.harvard.edu/abs/1969A%26A.....3..455M/abstract .. [3] https://ned.ipac.caltech.edu/level5/Stetson/Stetson2_2_1.html Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.psf import MoffatPSF model = MoffatPSF(flux=71.4, x_0=24.3, y_0=25.2, alpha=5.1, beta=3.2) yy, xx = np.mgrid[0:51, 0:51] data = model(xx, yy) plt.imshow(data, origin='lower', interpolation='nearest') """ flux = Parameter( default=1, description='Total integrated flux over the entire PSF.') x_0 = Parameter( default=0, description='Position of the peak along the x axis') y_0 = Parameter( default=0, description='Position of the peak along the y axis') alpha = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='Characteristic radius of the Moffat profile') beta = Parameter( default=2, bounds=(1.0 + FLOAT_EPSILON, None), fixed=True, description='Power-law index of the Moffat profile') def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, alpha=alpha.default, beta=beta.default, bbox_factor=10.0, **kwargs): super().__init__(flux=flux, x_0=x_0, y_0=y_0, alpha=alpha, beta=beta, **kwargs) self.bbox_factor = bbox_factor @property def fwhm(self): """ The FWHM of the Moffat profile. """ return 2.0 * self.alpha * np.sqrt(2 ** (1.0 / self.beta) - 1) def _calc_bounding_box(self, factor=10.0): """ Calculate a bounding box defining the limits of the model. Parameters ---------- factor : float, optional The multiple of the FWHM used to define the limits. Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ delta = factor * self.fwhm return ((self.y_0 - delta, self.y_0 + delta), (self.x_0 - delta, self.x_0 + delta)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from photutils.psf import MoffatPSF >>> model = MoffatPSF(x_0=0, y_0=0, alpha=2, beta=3) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-20.39298114135835, upper=20.39298114135835) y: Interval(lower=-20.39298114135835, upper=20.39298114135835) } model=MoffatPSF(inputs=('x', 'y')) order='C' ) >>> model.bbox_factor = 7 >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-14.27508679895084, upper=14.27508679895084) y: Interval(lower=-14.27508679895084, upper=14.27508679895084) } model=MoffatPSF(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box(factor=self.bbox_factor) def evaluate(self, x, y, flux, x_0, y_0, alpha, beta): """ Calculate the value of the 2D Moffat model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float Total integrated flux over the entire PSF. x_0, y_0 : float Position of the peak along the x and y axes. alpha : float, optional The characteristic radius of the Moffat profile. beta : float, optional The asymptotic power-law slope of the Moffat profile wings at large radial distances. Larger values provide less flux in the profile wings. For large ``beta``, this profile approaches a Gaussian profile. ``beta`` must be greater than 1. If ``beta`` is set to 1, then the Moffat profile is a Lorentz function, whose integral is infinite. For this normalized model, if ``beta`` is set to 1, then the profile will be zero everywhere. Returns ------- result : `~numpy.ndarray` The value of the model evaluated at the input coordinates. """ # output units should match the input flux units alpha2 = alpha.copy() if isinstance(alpha, u.Quantity): alpha2 = alpha.value amp = flux * (beta - 1) / (np.pi * alpha2 ** 2) r2 = (x - x_0) ** 2 + (y - y_0) ** 2 return amp * (1 + (r2 / alpha**2)) ** (-beta) @property def input_units(self): """ The input units of the model. """ x_unit = self.x_0.input_unit y_unit = self.y_0.input_unit if x_unit is None and y_unit is None: return None return {self.inputs[0]: x_unit, self.inputs[1]: y_unit} def _parameter_units_for_data_units(self, inputs_unit, outputs_unit): return {'x_0': inputs_unit[self.inputs[0]], 'y_0': inputs_unit[self.inputs[0]], 'alpha': inputs_unit[self.inputs[0]], 'flux': outputs_unit[self.outputs[0]]} class AiryDiskPSF(Fittable2DModel): r""" A 2D Airy disk PSF model. This model is evaluated by sampling the 2D Airy disk function at the input coordinates. The Airy disk profile is normalized such that the analytical integral over the entire 2D plane is equal to the total flux. Parameters ---------- flux : float, optional Total integrated flux over the entire PSF. x_0 : float, optional Position of the peak along the x axis. y_0 : float, optional Position of the peak along the y axis. radius : float, optional The radius of the Airy disk at the first zero. bbox_factor : float, optional The multiple of the FWHM used to define the bounding box limits. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` base class. See Also -------- GaussianPSF, CircularGaussianPSF, MoffatPSF Notes ----- The Airy disk profile is defined as: .. math:: f(r) = \frac{F}{4 \pi (R / R_z)^2} \left[ \frac{2 J_1\left(\frac{\pi r}{R / R_z}\right)} {\frac{\pi r}{R / R_z}} \right]^2 where :math:`r` is radial distance from the peak .. math:: r = \sqrt{(x - x_0)^2 + (y - y_0)^2} :math:`F` is the total integrated flux, :math:`J_1` is the first order `Bessel function `_ of the first kind, :math:`R` is the input ``radius`` parameter, and :math:`R_z = 1.2196698912665045` is the solution to the equation :math:`J_1(\pi R_z) = 0`. For an optical system, the radius of the first zero represents the limiting angular resolution. The limiting angular resolution is :math:`R_z \, \lambda / D \approx 1.22 \, \lambda / D`, where :math:`\lambda` is the wavelength of the light and :math:`D` is the diameter of the aperture. The full width at half maximum (FWHM) of the Airy disk profile is given by: .. math:: \rm{FWHM} = 1.028993969962188 \, \frac{R}{R_z} = 0.8436659602162364 \, R The model is normalized such that: .. math:: \int_{0}^{2 \pi} \int_{0}^{\infty} f(r) \,r \,dr \,d\theta = \int_{-\infty}^{\infty} \int_{-\infty}^{\infty} f(x, y) \,dx \,dy = F The ``radius`` parameter is fixed by default. If you wish to fit this parameter, set the ``fixed`` attribute to `False`, e.g.,:: >>> from photutils.psf import AiryDiskPSF >>> model = AiryDiskPSF() >>> model.radius.fixed = False By default, the ``radius`` parameter is bounded to be strictly positive. References ---------- .. [1] https://en.wikipedia.org/wiki/Airy_disk Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from astropy.visualization import simple_norm from photutils.psf import AiryDiskPSF model = AiryDiskPSF(flux=71.4, x_0=24.3, y_0=25.2, radius=5) yy, xx = np.mgrid[0:51, 0:51] data = model(xx, yy) norm = simple_norm(data, 'sqrt') plt.imshow(data, norm=norm, origin='lower', interpolation='nearest') """ flux = Parameter( default=1, description='Total integrated flux over the entire PSF.') x_0 = Parameter( default=0, description='Position of the peak along the x axis') y_0 = Parameter( default=0, description='Position of the peak along the y axis') radius = Parameter( default=1, bounds=(FLOAT_EPSILON, None), fixed=True, description='Radius of the Airy disk at the first zero') _rz = jn_zeros(1, 1)[0] / np.pi def __init__(self, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, radius=radius.default, bbox_factor=10.0, **kwargs): super().__init__(flux=flux, x_0=x_0, y_0=y_0, radius=radius, **kwargs) self.bbox_factor = bbox_factor @property def fwhm(self): """ The FWHM of the Airy disk profile. """ return 2.0 * 1.616339948310703 * self.radius / self._rz / np.pi def _calc_bounding_box(self, factor=10.0): """ Calculate a bounding box defining the limits of the model. Parameters ---------- factor : float, optional The multiple of the FWHM used to define the limits. Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ delta = factor * self.fwhm return ((self.y_0 - delta, self.y_0 + delta), (self.x_0 - delta, self.x_0 + delta)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from photutils.psf import AiryDiskPSF >>> model = AiryDiskPSF(x_0=0, y_0=0, radius=3) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-25.30997880648709, upper=25.30997880648709) y: Interval(lower=-25.30997880648709, upper=25.30997880648709) } model=AiryDiskPSF(inputs=('x', 'y')) order='C' ) >>> model.bbox_factor = 7 >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-17.71698516454096, upper=17.71698516454096) y: Interval(lower=-17.71698516454096, upper=17.71698516454096) } model=AiryDiskPSF(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box(factor=self.bbox_factor) def evaluate(self, x, y, flux, x_0, y_0, radius): """ Calculate the value of the 2D Airy disk model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float Total integrated flux over the entire PSF. x_0, y_0 : float Position of the peak along the x and y axes. radius : float, optional The radius of the Airy disk at the first zero. Returns ------- result : `~numpy.ndarray` The value of the model evaluated at the input coordinates. """ r = np.sqrt((x - x_0) ** 2 + (y - y_0) ** 2) / (radius / self._rz) if isinstance(r, u.Quantity): # scipy function cannot handle Quantity, so turn into array r = r.to_value(u.dimensionless_unscaled) # Since r can be zero, we have to take care to treat that case # separately so as not to raise a numpy warning z = np.ones(r.shape) rt = np.pi * r[r > 0] z[r > 0] = (2.0 * j1(rt) / rt) ** 2 if isinstance(flux, u.Quantity): # make z a quantity to allow in-place multiplication z <<= u.dimensionless_unscaled normalization = (4.0 / np.pi) * (radius / self._rz) ** 2 if isinstance(normalization, u.Quantity): normalization = normalization.value z *= (flux / normalization) return z @property def input_units(self): """ The input units of the model. """ x_unit = self.x_0.input_unit y_unit = self.y_0.input_unit if x_unit is None and y_unit is None: return None return {self.inputs[0]: x_unit, self.inputs[1]: y_unit} def _parameter_units_for_data_units(self, inputs_unit, outputs_unit): return {'x_0': inputs_unit[self.inputs[0]], 'y_0': inputs_unit[self.inputs[0]], 'radius': inputs_unit[self.inputs[0]], 'flux': outputs_unit[self.outputs[0]]} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/gridded_models.py0000644000175100001660000005646214755160622021340 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines gridded PSF models. """ import copy import itertools import numpy as np from astropy.io import registry from astropy.modeling import Fittable2DModel, Parameter from astropy.nddata import NDData from astropy.utils.decorators import lazyproperty from scipy.interpolate import RectBivariateSpline from photutils.psf.model_io import (GriddedPSFModelRead, _get_metadata, _read_stdpsf, is_stdpsf, is_webbpsf, stdpsf_reader, webbpsf_reader) from photutils.psf.model_plotting import ModelGridPlotMixin from photutils.utils._parameters import as_pair __all__ = ['GriddedPSFModel', 'STDPSFGrid'] __doctest_skip__ = ['STDPSFGrid'] class GriddedPSFModel(ModelGridPlotMixin, Fittable2DModel): """ A model for a grid of 2D ePSF models. The ePSF models are defined at fiducial detector locations and are bilinearly interpolated to calculate an ePSF model at an arbitrary (x, y) detector position. The fiducial detector locations are must form a rectangular grid. The model has three model parameters: an image intensity scaling factor (``flux``) which is applied to the input image, and two positional parameters (``x_0`` and ``y_0``) indicating the location of a feature in the coordinate grid on which the model is evaluated. When evaluating this model, it cannot be called with x and y arrays that have greater than 2 dimensions. Parameters ---------- nddata : `~astropy.nddata.NDData` A `~astropy.nddata.NDData` object containing the grid of reference ePSF arrays. The data attribute must contain a 3D `~numpy.ndarray` containing a stack of the 2D ePSFs with a shape of ``(N_psf, ePSF_ny, ePSF_nx)``. The length of the x and y axes must both be at least 4. All elements of the input image data must be finite. The PSF peak is assumed to be located at the center of the input image. The array must be normalized so that the total flux of a source is 1.0. This means that the sum of the values in the input image PSF over an infinite grid is 1.0. In practice, the sum of the data values in the input image may be less than 1.0 if the input image only covers a finite region of the PSF. These correction factors can be estimated from the ensquared or encircled energy of the PSF based on the size of the input image. The meta attribute must be dictionary containing the following: * ``'grid_xypos'``: A list of the (x, y) grid positions of each reference ePSF. The order of positions should match the first axis of the 3D `~numpy.ndarray` of ePSFs. In other words, ``grid_xypos[i]`` should be the (x, y) position of the reference ePSF defined in ``nddata.data[i]``. The grid positions must form a rectangular grid. * ``'oversampling'``: The integer oversampling factor(s) of the input ePSF images. If ``oversampling`` is a scalar then it will be used for both axes. If ``oversampling`` has two elements, they must be in ``(y, x)`` order. The meta attribute may contain other properties such as the telescope, instrument, detector, and filter of the ePSF. flux : float, optional The flux scaling factor for the model. This is the total flux of the source, assuming the input ePSF images are properly normalized. x_0, y_0 : float, optional The (x, y) position of the PSF peak in the image in the output coordinate grid on which the model is evaluated. fill_value : float, optional The value to use for points outside of the input pixel grid. The default is 0.0. Methods ------- read(*args, **kwargs) Class method to create a `GriddedPSFModel` instance from a STDPSF FITS file. This method uses :func:`~photutils.psf.stdpsf_reader` with the provided parameters. Notes ----- Internally, the grid of ePSFs will be arranged and stored such that it is sorted first by the y reference pixel coordinate and then by the x reference pixel coordinate. """ flux = Parameter(description='Intensity scaling factor for the ePSF ' 'model.', default=1.0) x_0 = Parameter(description='x position in the output coordinate grid ' 'where the model is evaluated.', default=0.0) y_0 = Parameter(description='y position in the output coordinate grid ' 'where the model is evaluated.', default=0.0) read = registry.UnifiedReadWriteMethod(GriddedPSFModelRead) def __init__(self, nddata, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, fill_value=0.0): self.data, self.grid_xypos = self._define_grid(nddata) self._meta = nddata.meta.copy() # _meta to avoid the meta descriptor self._oversampling = as_pair('oversampling', nddata.meta['oversampling'], lower_bound=(0, 1)) self.fill_value = fill_value self._xgrid = np.unique(self.grid_xypos[:, 0]) # sorted self._ygrid = np.unique(self.grid_xypos[:, 1]) # sorted self.meta['grid_shape'] = (len(self._ygrid), len(self._xgrid)) self._interpolator = {} super().__init__(flux, x_0, y_0) @staticmethod def _validate_data(data): """ Validate the input ePSF data. Parameters ---------- data : `~astropy.nddata.NDData` The input NDData object containing the ePSF data. Raises ------ TypeError If the input data is not an NDData instance. ValueError If the input data is not a 3D numpy ndarray or if the input data contains NaNs or infs. """ if not isinstance(data, NDData): raise TypeError('data must be an NDData instance.') if data.data.ndim != 3: raise ValueError('The NDData data attribute must be a 3D numpy ' 'ndarray') if not np.all(np.isfinite(data.data)): raise ValueError('All elements of input data must be finite.') # this is required by RectBivariateSpline for kx=3, ky=3 if np.any(np.array(data.data.shape[1:]) < 4): raise ValueError('The length of the PSF x and y axes must both ' 'be at least 4.') if 'oversampling' not in data.meta: raise ValueError('"oversampling" must be in the nddata meta ' 'dictionary.') @staticmethod def _is_rectangular_grid(grid_xypos): """ Check if the input ``grid_xypos`` forms a rectangular grid. Parameters ---------- grid_xypos : 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. """ xgrid = np.unique(grid_xypos[:, 0]) # sorted ygrid = np.unique(grid_xypos[:, 1]) # sorted expected_points = {(x, y) for x in xgrid for y in ygrid} grid = set(map(tuple, grid_xypos)) return grid == expected_points def _validate_grid(self, data): """ Validate the input ePSF grid. Parameters ---------- data : `~astropy.nddata.NDData` The input NDData object containing the ePSF data. Raises ------ ValueError If the input grid_xypos does not form a rectangular grid. """ try: grid_xypos = np.array(data.meta['grid_xypos']) except KeyError as exc: raise ValueError('"grid_xypos" must be in the nddata meta ' 'dictionary.') from exc if len(grid_xypos) != data.data.shape[0]: raise ValueError('The length of grid_xypos must match the number ' 'of input ePSFs.') if not self._is_rectangular_grid(grid_xypos): raise ValueError('grid_xypos must form a rectangular grid.') def _define_grid(self, nddata): """ Sort the input ePSF data into a rectangular grid where the ePSFs are sorted first by y and then by x. Parameters ---------- nddata : `~astropy.nddata.NDData` The input NDData object containing the ePSF data. Returns ------- data : 3D `~numpy.ndarray` The 3D array of ePSFs. grid_xypos : array of (x, y) pairs The (x, y) positions of the ePSFs, sorted first by y and then by x. """ self._validate_data(nddata) self._validate_grid(nddata) grid_xypos = np.array(nddata.meta['grid_xypos']) # sort by y and then by x (last key is primary) idx = np.lexsort((grid_xypos[:, 0], grid_xypos[:, 1])) return nddata.data[idx], grid_xypos[idx] def _cls_info(self): cls_info = [] keys = ('STDPSF', 'instrument', 'detector', 'filter', 'grid_shape') for key in keys: if key in self.meta: name = key.capitalize() if key != 'STDPSF' else key cls_info.append((name, self.meta[key])) cls_info.extend([('Number of PSFs', len(self.grid_xypos)), ('PSF shape (oversampled pixels)', self.data.shape[1:]), ('Oversampling', tuple(self.oversampling))]) return cls_info def __str__(self): return self._format_str(keywords=self._cls_info()) def copy(self): """ Return a copy of this model where only the model parameters are copied. All other copied model attributes are references to the original model. This prevents copying the ePSF grid data, which may contain a large array. This method is useful if one is interested in only changing the model parameters in a model copy. It is used in the PSF photometry classes during model fitting. Use the `deepcopy` method if you want to copy all of the model attributes, including the ePSF grid data. Returns ------- result : `GriddedPSFModel` A copy of this model with only the model parameters copied. """ newcls = object.__new__(self.__class__) for key, val in self.__dict__.items(): if key in self.param_names: # copy only the parameter values newcls.__dict__[key] = copy.copy(val) else: newcls.__dict__[key] = val return newcls def deepcopy(self): """ Return a deep copy of this model. Returns ------- result : `GriddedPSFModel` A deep copy of this model. """ return copy.deepcopy(self) @property def oversampling(self): """ The integer oversampling factor(s) of the input ePSF images. If ``oversampling`` is a scalar then it will be used for both axes. If ``oversampling`` has two elements, they must be in ``(y, x)`` order. """ return self._oversampling @oversampling.setter def oversampling(self, value): """ Set the oversampling factor(s) of the input ePSF images. Parameters ---------- value : int or tuple of int The integer oversampling factor(s) of the input ePSF images. If ``oversampling`` is a scalar then it will be used for both axes. If ``oversampling`` has two elements, they must be in ``(y, x)`` order. """ self._oversampling = as_pair('oversampling', value, lower_bound=(0, 1)) def _calc_bounding_box(self): """ Set a bounding box defining the limits of the model. Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ dy, dx = np.array(self.data.shape[1:]) / 2 / self.oversampling return ((self.y_0 - dy, self.y_0 + dy), (self.x_0 - dx, self.x_0 + dx)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from itertools import product >>> import numpy as np >>> from astropy.nddata import NDData >>> from photutils.psf import GaussianPSF, GriddedPSFModel >>> psfs = [] >>> yy, xx = np.mgrid[0:101, 0:101] >>> for i in range(16): ... theta = np.deg2rad(i * 10.0) ... gmodel = GaussianPSF(flux=1, x_0=50, y_0=50, x_fwhm=10, ... y_fwhm=5, theta=theta) ... psfs.append(gmodel(xx, yy)) >>> xgrid = [0, 40, 160, 200] >>> ygrid = [0, 60, 140, 200] >>> meta = {} >>> meta['grid_xypos'] = list(product(xgrid, ygrid)) >>> meta['oversampling'] = 4 >>> nddata = NDData(psfs, meta=meta) >>> model = GriddedPSFModel(nddata, flux=1, x_0=0, y_0=0) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-12.625, upper=12.625) y: Interval(lower=-12.625, upper=12.625) } model=GriddedPSFModel(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box() @lazyproperty def origin(self): """ A 1D `~numpy.ndarray` (x, y) pixel coordinates within the model's 2D image of the origin of the coordinate system. """ xyorigin = (np.array(self.data.shape) - 1) / 2 return xyorigin[::-1] @lazyproperty def _interp_xyidx(self): """ The x and y indices for the interpolator. """ xidx = np.arange(self.data.shape[2]) yidx = np.arange(self.data.shape[1]) return xidx, yidx def _calc_interpolator(self, grid_idx): """ Calculate the `~scipy.interpolate.RectBivariateSpline` interpolator for an input ePSF image at the given reference (x, y) position. The resulting interpolator is cached in the `_interpolator` dictionary for reuse. """ xypos = tuple(self.grid_xypos[grid_idx]) if xypos in self._interpolator: return self._interpolator[xypos] # RectBivariateSpline expects the data to be in (x, y) axis order data = self.data[grid_idx] interp = RectBivariateSpline(*self._interp_xyidx, data.T, kx=3, ky=3, s=0) self._interpolator[xypos] = interp return interp def _find_bounding_points(self, x, y): """ Find the grid indices and reference (x, y) points of the four bounding grid points for a given (x, y) coordinate. If the point is outside the grid, the nearest grid points are selected. The input grid points do not need to be sorted. Parameters ---------- x, y : float The (x_0, y_0) position of the model. Returns ------- grid_idx : `~numpy.ndarray` The indices of the four bounding points in the sorted grid. The order is lower-left, lower-right, upper-left, upper-right. grid_xy : `~numpy.ndarray` The x and y coordinates of the four bounding points. The order is left, right, bottom, top. """ # Find the insertion indices for x and y in the sorted grids xidx = np.searchsorted(self._xgrid, x) - 1 yidx = np.searchsorted(self._ygrid, y) - 1 # Clip the indices to valid ranges xidx = np.clip(xidx, 0, len(self._xgrid) - 2) yidx = np.clip(yidx, 0, len(self._ygrid) - 2) # Find the four bounding points in the sorted grid # (x0, y0) is the lower-left corner of the grid # (x1, y1) is the upper-right corner of the grid x0, x1 = self._xgrid[xidx], self._xgrid[xidx + 1] y0, y1 = self._ygrid[yidx], self._ygrid[yidx + 1] # Find the indices of these points in grid_xypos xcoords, ycoords = self.grid_xypos.T lower_left = np.where((xcoords == x0) & (ycoords == y0))[0][0] lower_right = np.where((xcoords == x1) & (ycoords == y0))[0][0] upper_left = np.where((xcoords == x0) & (ycoords == y1))[0][0] upper_right = np.where((xcoords == x1) & (ycoords == y1))[0][0] grid_idx = np.array((lower_left, lower_right, upper_left, upper_right)) grid_xy = np.array((x0, x1, y0, y1)) return grid_idx, grid_xy def _calc_bilinear_weights(self, xi, yi, grid_xy): """ Calculate the bilinear interpolation weights for a given (xi, yi) coordinate and the four bounding grid points. Parameters ---------- xi, yi : float The (x_0, y_0) position of the model. grid_xy : `~numpy.ndarray` The x and y coordinates of the four bounding points. The order is left, right, bottom, top. Returns ------- weights : `~numpy.ndarray` The bilinear interpolation weights for the four bounding points. The order is lower-left, lower-right, upper-left, upper-right. """ x0, x1, y0, y1 = grid_xy xi = np.clip(xi, x0, x1) yi = np.clip(yi, y0, y1) norm = (x1 - x0) * (y1 - y0) # lower-left, lower-right, upper-left, upper-right return np.array([(x1 - xi) * (y1 - yi), (xi - x0) * (y1 - yi), (x1 - xi) * (yi - y0), (xi - x0) * (yi - y0)]) / norm def _calc_model_values(self, x_0, y_0, xi, yi): """ Calculate the ePSF model at a given (x_0, y_0) model coordinate and the input (xi, yi) coordinate. Parameters ---------- x_0, y_0 : float The (x, y) position of the model. xi, yi : float The input (x, y) coordinates at which the model is evaluated. Returns ------- result : float or `~numpy.ndarray` The interpolated ePSF model at the input (x_0, y_0) coordinate. """ grid_idx, grid_xy = self._find_bounding_points(x_0, y_0) interpolators = np.array([self._calc_interpolator(gidx) for gidx in grid_idx]) weights = self._calc_bilinear_weights(x_0, y_0, grid_xy) idx = np.where(weights != 0) interpolators = interpolators[idx] weights = weights[idx] result = 0 for interp, weight in zip(interpolators, weights, strict=True): result += interp(xi, yi, grid=False) * weight return result def evaluate(self, x, y, flux, x_0, y_0): """ Calculate the ePSF model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or `~numpy.ndarray` The x and y positions at which to evaluate the model. flux : float The flux scaling factor for the model. x_0, y_0 : float The (x, y) position of the model. Returns ------- evaluated_model : `~numpy.ndarray` The evaluated model. """ if x.ndim > 2: raise ValueError('x and y must be 1D or 2D.') # 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] evaluated_model = flux * self._calc_model_values(x_0, y_0, xi, yi) if self.fill_value is not None: # set pixels that are outside the input pixel grid to the # fill_value to avoid extrapolation; these bounds match the # RegularGridInterpolator bounds ny, nx = self.data.shape[1:] invalid = (xi < 0) | (xi > nx - 1) | (yi < 0) | (yi > ny - 1) evaluated_model[invalid] = self.fill_value return evaluated_model class STDPSFGrid(ModelGridPlotMixin): """ Class to read and plot "STDPSF" format ePSF model grids. STDPSF files are FITS files that contain a 3D array of ePSFs with the header detailing where the fiducial ePSFs are located in the detector coordinate frame. The oversampling factor for STDPSF FITS files is assumed to be 4. Parameters ---------- filename : str The name of the STDPDF FITS file. A URL can also be used. Examples -------- >>> from photutils.psf import STDPSFGrid >>> psfgrid = STDPSFGrid('STDPSF_ACSWFC_F814W.fits') >>> fig = psfgrid.plot_grid() >>> fig.show() """ def __init__(self, filename): grid_data = _read_stdpsf(filename) self.data = grid_data['data'] self._xgrid = grid_data['xgrid'] self._ygrid = grid_data['ygrid'] xy_grid = [yx[::-1] for yx in itertools.product(self._ygrid, self._xgrid)] oversampling = 4 # assumption for STDPSF files self.grid_xypos = xy_grid self.oversampling = as_pair('oversampling', oversampling, lower_bound=(0, 1)) meta = {'grid_shape': (len(self._ygrid), len(self._xgrid)), 'grid_xypos': xy_grid, 'oversampling': oversampling} # try to get additional metadata from the filename because this # information is not currently available in the FITS headers file_meta = _get_metadata(filename, None) if file_meta is not None: meta.update(file_meta) self.meta = meta def __str__(self): cls_name = f'<{self.__class__.__module__}.{self.__class__.__name__}>' cls_info = [] keys = ('STDPSF', 'detector', 'filter', 'grid_shape') for key in keys: if key in self.meta: name = key.capitalize() if key != 'STDPSF' else key cls_info.append((name, self.meta[key])) cls_info.extend([('Number of PSFs', len(self.grid_xypos)), ('PSF shape (oversampled pixels)', self.data.shape[1:]), ('Oversampling', self.oversampling)]) with np.printoptions(threshold=25, edgeitems=5): fmt = [f'{key}: {val}' for key, val in cls_info] return f'{cls_name}\n' + '\n'.join(fmt) def __repr__(self): return self.__str__() with registry.delay_doc_updates(GriddedPSFModel): registry.register_reader('stdpsf', GriddedPSFModel, stdpsf_reader) registry.register_identifier('stdpsf', GriddedPSFModel, is_stdpsf) registry.register_reader('webbpsf', GriddedPSFModel, webbpsf_reader) registry.register_identifier('webbpsf', GriddedPSFModel, is_webbpsf) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/groupers.py0000644000175100001660000000514514755160622020231 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides classes to perform grouping of stars. """ from collections import defaultdict import numpy as np from scipy.cluster.hierarchy import fclusterdata __all__ = ['SourceGrouper'] class SourceGrouper: """ Class to group sources into clusters based on a minimum separation distance. The groups are formed using hierarchical agglomerative clustering with a distance criterion, calling the `scipy.cluster.hierarchy.fclusterdata` function. Parameters ---------- min_separation : float The minimum distance (in pixels) such that any two sources separated by less than this distance will be placed in the same group if the ``min_size`` criteria is also met. """ def __init__(self, min_separation): self.min_separation = min_separation def __call__(self, x, y): """ Group sources into clusters based on a minimum distance criteria. Parameters ---------- x, y : 1D float `~numpy.ndarray` The 1D arrays of the x and y centroid coordinates of the sources. Returns ------- result : 1D int `~numpy.ndarray` A 1D array of the groups, in the same order as the input x and y coordinates. """ return self._group_sources(x, y) def _group_sources(self, x, y): """ Group sources into clusters based on a minimum distance criteria. Parameters ---------- x, y : 1D float `~numpy.ndarray` The 1D arrays of the x and y centroid coordinates of the sources. Returns ------- result : 1D int `~numpy.ndarray` A 1D array of the groups, in the same order as the input x and y coordinates. """ x = np.atleast_1d(x) y = np.atleast_1d(y) if x.shape != y.shape: raise ValueError('x and y must have the same shape') if x.shape == (0,): # no sources raise ValueError('x and y must not be empty') if x.shape == (1,): # single source -> single group return np.array([1]) xypos = np.transpose((x, y)) group_id = fclusterdata(xypos, t=self.min_separation, criterion='distance') # reorder the group_ids so that unique group_ids start from 1 # and increase (this matches the output of DBSCAN) mapping = defaultdict(lambda: len(mapping) + 1) return np.array([mapping[group] for group in group_id]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/image_models.py0000644000175100001660000014110414755160622021004 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides image-based PSF models. """ import copy import warnings import numpy as np from astropy.modeling import Fittable2DModel, Parameter from astropy.utils.decorators import deprecated, lazyproperty from astropy.utils.exceptions import AstropyUserWarning from scipy.interpolate import RectBivariateSpline from photutils.aperture import CircularAperture from photutils.utils._parameters import as_pair __all__ = ['EPSFModel', 'FittableImageModel', 'ImagePSF'] class ImagePSF(Fittable2DModel): """ A model for a 2D image PSF. This class takes 2D image data and computes the values of the model at arbitrary locations, including fractional pixel positions, within the image using spline interpolation provided by :py:class:`~scipy.interpolate.RectBivariateSpline`. The model has three model parameters: an image intensity scaling factor (``flux``) which is applied to the input image, and two positional parameters (``x_0`` and ``y_0``) indicating the location of a feature in the coordinate grid on which the model is evaluated. Parameters ---------- data : 2D `~numpy.ndarray` Array containing the 2D image. The length of the x and y axes must both be at least 4. All elements of the input image data must be finite. By default, the PSF peak is assumed to be located at the center of the input image (see the ``origin`` keyword). The array must be normalized so that the total flux of a source is 1.0. This means that the sum of the values in the input image PSF over an infinite grid is 1.0. In practice, the sum of the data values in the input image may be less than 1.0 if the input image only covers a finite region of the PSF. These correction factors can be estimated from the ensquared or encircled energy of the PSF based on the size of the input image. flux : float, optional The total flux of the source, assuming the input image was properly normalized. x_0, y_0 : float The x and y positions of a feature in the image in the output coordinate grid on which the model is evaluated. Typically, this refers to the position of the PSF peak, which is assumed to be located at the center of the input image (see the ``origin`` keyword). origin : tuple of 2 float or None, optional The ``(x, y)`` coordinate with respect to the input image data array that represents the reference pixel of the input data. The reference ``origin`` pixel will be placed at the model ``x_0`` and ``y_0`` coordinates in the output coordinate system on which the model is evaluated. Most typically, the input PSF should be centered in the input image, and thus the origin should be set to the central pixel of the ``data`` array. If the origin is set to `None`, then the origin will be set to the center of the ``data`` array (``(npix - 1) / 2.0``). oversampling : int or array_like (int), optional The integer oversampling factor(s) of the input PSF image. If ``oversampling`` is a scalar then it will be used for both axes. If ``oversampling`` has two elements, they must be in ``(y, x)`` order. fill_value : float, optional The value to use for points outside of the input pixel grid. The default is 0.0. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` base class. See Also -------- GriddedPSFModel : A model for a grid of ePSF models. Examples -------- In this simple example, we create a PSF image model from a Circular Gaussian PSF. In this case, one should use the `CircularGaussianPSF` model directly as a PSF model. However, this example demonstrates how to create an image PSF model from an input image. .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.psf import CircularGaussianPSF, ImagePSF gaussian_psf = CircularGaussianPSF(x_0=12, y_0=12, fwhm=3.2) yy, xx = np.mgrid[:25, :25] psf_data = gaussian_psf(xx, yy) psf_model = ImagePSF(psf_data, x_0=12, y_0=12, flux=10) data = psf_model(xx, yy) plt.imshow(data, origin='lower') """ flux = Parameter(default=1, description='Intensity scaling factor of the image.') x_0 = Parameter(default=0, description=('Position of a feature in the image along ' 'the x axis')) y_0 = Parameter(default=0, description=('Position of a feature in the image along ' 'the y axis')) def __init__(self, data, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, origin=None, oversampling=1, fill_value=0.0, **kwargs): self._validate_data(data) self.data = data self.origin = origin self.oversampling = as_pair('oversampling', oversampling, lower_bound=(0, 1)) self.fill_value = fill_value super().__init__(flux, x_0, y_0, **kwargs) @staticmethod def _validate_data(data): if not isinstance(data, np.ndarray): raise TypeError('Input data must be a 2D numpy array.') if data.ndim != 2: raise ValueError('Input data must be a 2D numpy array.') if not np.all(np.isfinite(data)): raise ValueError('All elements of input data must be finite.') # this is required by RectBivariateSpline for kx=3, ky=3 if np.any(np.array(data.shape) < 4): raise ValueError('The length of the x and y axes must both be at ' 'least 4.') def _cls_info(self): return [('PSF shape (oversampled pixels)', self.data.shape), ('Oversampling', tuple(self.oversampling))] def __str__(self): return self._format_str(keywords=self._cls_info()) def copy(self): """ Return a copy of this model where only the model parameters are copied. All other copied model attributes are references to the original model. This prevents copying the image data, which may be a large array. This method is useful if one is interested in only changing the model parameters in a model copy. It is used in the PSF photometry classes during model fitting. Use the `deepcopy` method if you want to copy all of the model attributes, including the PSF image data. Returns ------- result : `ImagePSF` A copy of this model with only the model parameters copied. """ newcls = object.__new__(self.__class__) for key, val in self.__dict__.items(): if key in self.param_names: # copy only the parameter values newcls.__dict__[key] = copy.copy(val) else: newcls.__dict__[key] = val return newcls def deepcopy(self): """ Return a deep copy of this model. Returns ------- result : `ImagePSF` A deep copy of this model. """ return copy.deepcopy(self) @property def origin(self): """ A 1D `~numpy.ndarray` (x, y) pixel coordinates within the model's 2D image of the origin of the coordinate system. The reference ``origin`` pixel will be placed at the model ``x_0`` and ``y_0`` coordinates in the output coordinate system on which the model is evaluated. Most typically, the input PSF should be centered in the input image, and thus the origin should be set to the central pixel of the ``data`` array. If the origin is set to `None`, then the origin will be set to the center of the ``data`` array (``(npix - 1) / 2.0``). """ return self._origin @origin.setter def origin(self, origin): if origin is None: origin = (np.array(self.data.shape) - 1.0) / 2.0 origin = origin[::-1] # flip to (x, y) order else: origin = np.asarray(origin) if origin.ndim != 1 or len(origin) != 2: raise ValueError('origin must be 1D and have 2-elements') if not np.all(np.isfinite(origin)): raise ValueError('All elements of origin must be finite') self._origin = origin @lazyproperty def interpolator(self): """ The interpolating spline function. The interpolator is computed with a 3rd-degree `~scipy.interpolate.RectBivariateSpline` (kx=3, ky=3, s=0) using the input image data. The interpolator is used to evaluate the model at arbitrary locations, including fractional pixel positions. Notes ----- This property can be overridden in a subclass to define custom interpolators. """ x = np.arange(self.data.shape[1]) y = np.arange(self.data.shape[0]) # RectBivariateSpline expects the data to be in (x, y) axis order return RectBivariateSpline(x, y, self.data.T, kx=3, ky=3, s=0) def _calc_bounding_box(self): """ Set a bounding box defining the limits of the model. Returns ------- bbox : tuple A bounding box defining the ((y_min, y_max), (x_min, x_max)) limits of the model. """ dy, dx = np.array(self.data.shape) / 2 / self.oversampling # apply the origin shift # if origin is None, the origin is set to the center of the # image and the shift is 0 xshift = np.array(self.data.shape[1] - 1) / 2 - self.origin[0] yshift = np.array(self.data.shape[0] - 1) / 2 - self.origin[1] xshift /= self.oversampling[1] yshift /= self.oversampling[0] return ((self.y_0 - dy + yshift, self.y_0 + dy + yshift), (self.x_0 - dx + xshift, self.x_0 + dx + xshift)) @property def bounding_box(self): """ The bounding box of the model. Examples -------- >>> from photutils.psf import ImagePSF >>> psf_data = np.arange(30, dtype=float).reshape(5, 6) >>> psf_data /= np.sum(psf_data) >>> model = ImagePSF(psf_data, flux=1, x_0=0, y_0=0) >>> model.bounding_box # doctest: +FLOAT_CMP ModelBoundingBox( intervals={ x: Interval(lower=-3.0, upper=3.0) y: Interval(lower=-2.5, upper=2.5) } model=ImagePSF(inputs=('x', 'y')) order='C' ) """ return self._calc_bounding_box() def evaluate(self, x, y, flux, x_0, y_0): """ Calculate the value of the image model at the input coordinates for the given model parameters. Parameters ---------- x, y : float or array_like The x and y coordinates at which to evaluate the model. flux : float The total flux of the source, assuming the input image was properly normalized. x_0, y_0 : float The x and y positions of the feature in the image in the output coordinate grid on which the model is evaluated. Returns ------- result : `~numpy.ndarray` The value of the model evaluated at the input coordinates. """ xi = self.oversampling[1] * (np.asarray(x, dtype=float) - x_0) yi = self.oversampling[0] * (np.asarray(y, dtype=float) - y_0) xi += self._origin[0] yi += self._origin[1] evaluated_model = flux * self.interpolator(xi, yi, grid=False) if self.fill_value is not None: # set pixels that are outside the input pixel grid to the # fill_value to avoid extrapolation; these bounds match the # RegularGridInterpolator bounds ny, nx = self.data.shape invalid = (xi < 0) | (xi > nx - 1) | (yi < 0) | (yi > ny - 1) evaluated_model[invalid] = self.fill_value return evaluated_model @deprecated('2.0.0', alternative='`ImagePSF`') class FittableImageModel(Fittable2DModel): r""" A fittable image model allowing for intensity scaling and translations. This class takes 2D image data and computes the values of the model at arbitrary locations, including fractional pixel positions, within the image using spline interpolation provided by :py:class:`~scipy.interpolate.RectBivariateSpline`. The fittable model provided by this class has three model parameters: an image intensity scaling factor (``flux``) which is applied to (normalized) image, and two positional parameters (``x_0`` and ``y_0``) indicating the location of a feature in the coordinate grid on which the model is to be evaluated. Parameters ---------- data : 2D `~numpy.ndarray` Array containing the 2D image. flux : float, optional Intensity scaling factor for image data. If ``flux`` is `None`, then the normalization constant will be computed so that the total flux of the model's image data is 1.0. x_0, y_0 : float, optional Position of a feature in the image in the output coordinate grid on which the model is evaluated. normalize : bool, optional Indicates whether or not the model should be build on normalized input image data. If true, then the normalization constant (*N*) is computed so that .. math:: N \cdot C \cdot \sum\limits_{i,j} D_{i,j} = 1, where *N* is the normalization constant, *C* is correction factor given by the parameter ``normalization_correction``, and :math:`D_{i,j}` are the elements of the input image ``data`` array. normalization_correction : float, optional A strictly positive number that represents correction that needs to be applied to model's data normalization (see *C* in the equation in the comments to ``normalize`` for more details). A possible application for this parameter is to account for aperture correction. Assuming model's data represent a PSF to be fitted to some target star, we set ``normalization_correction`` to the aperture correction that needs to be applied to the model. That is, ``normalization_correction`` in this case should be set to the ratio between the total flux of the PSF (including flux outside model's data) to the flux of model's data. Then, best fitted value of the ``flux`` model parameter will represent an aperture-corrected flux of the target star. In the case of aperture correction, ``normalization_correction`` should be a value larger than one, as the total flux, including regions outside of the aperture, should be larger than the flux inside the aperture, and thus the correction is applied as an inversely multiplied factor. origin : tuple, None, optional A reference point in the input image ``data`` array. When origin is `None`, origin will be set at the middle of the image array. If ``origin`` represents the location of a feature (e.g., the position of an intensity peak) in the input ``data``, then model parameters ``x_0`` and ``y_0`` show the location of this peak in an another target image to which this model was fitted. Fundamentally, it is the coordinate in the model's image data that should map to coordinate (``x_0``, ``y_0``) of the output coordinate system on which the model is evaluated. Alternatively, when ``origin`` is set to ``(0, 0)``, then model parameters ``x_0`` and ``y_0`` are shifts by which model's image should be translated in order to match a target image. oversampling : int or array_like (int) The integer oversampling factor(s) of the ePSF relative to the input ``stars`` along each axis. If ``oversampling`` is a scalar then it will be used for both axes. If ``oversampling`` has two elements, they must be in ``(y, x)`` order. fill_value : float, optional The value to be returned by the `evaluate` or ``astropy.modeling.Model.__call__`` methods when evaluation is performed outside the definition domain of the model. **kwargs : dict, optional Additional optional keyword arguments to be passed directly to the `compute_interpolator` method. See `compute_interpolator` for more details. """ flux = Parameter(description='Intensity scaling factor for image data.', default=1.0) x_0 = Parameter(description='X-position of a feature in the image in ' 'the output coordinate grid on which the model is ' 'evaluated.', default=0.0) y_0 = Parameter(description='Y-position of a feature in the image in ' 'the output coordinate grid on which the model is ' 'evaluated.', default=0.0) def __init__(self, data, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, normalize=False, normalization_correction=1.0, origin=None, oversampling=1, fill_value=0.0, **kwargs): self._fill_value = fill_value self._img_norm = None self._normalization_status = 0 if normalize else 2 self._store_interpolator_kwargs(**kwargs) self._oversampling = as_pair('oversampling', oversampling, lower_bound=(0, 1)) if normalization_correction <= 0: raise ValueError("'normalization_correction' must be strictly " 'positive.') self._normalization_correction = normalization_correction self._normalization_constant = 1.0 / self._normalization_correction self._data = np.array(data, copy=True, dtype=float) if not np.all(np.isfinite(self._data)): raise ValueError("All elements of input 'data' must be finite.") # set input image related parameters: self._ny, self._nx = self._data.shape self._shape = self._data.shape if self._data.size < 1: raise ValueError('Image data array cannot be zero-sized.') # set the origin of the coordinate system in image's pixel grid: self.origin = origin flux = self._initial_norm(flux, normalize) super().__init__(flux, x_0, y_0) # initialize interpolator: self.compute_interpolator(**kwargs) def _initial_norm(self, flux, normalize): if flux is None: if self._img_norm is None: self._img_norm = self._compute_raw_image_norm() flux = self._img_norm self._compute_normalization(normalize) return flux def _compute_raw_image_norm(self): """ Helper function that computes the uncorrected inverse normalization factor of input image data. This quantity is computed as the *sum of all pixel values*. .. note:: This function is intended to be overridden in a subclass if one desires to change the way the normalization factor is computed. """ return np.sum(self._data, dtype=float) def _compute_normalization(self, normalize=True): r""" Helper function that computes (corrected) normalization factor of the original image data. This quantity is computed as the inverse "raw image norm" (or total "flux" of model's image) corrected by the ``normalization_correction``: .. math:: N = 1/(\Phi * C), where :math:`\Phi` is the "total flux" of model's image as computed by `_compute_raw_image_norm` and *C* is the normalization correction factor. :math:`\Phi` is computed only once if it has not been previously computed. Otherwise, the existing (stored) value of :math:`\Phi` is not modified as :py:class:`FittableImageModel` does not allow image data to be modified after the object is created. .. note:: Normally, this function should not be called by the end-user. It is intended to be overridden in a subclass if one desires to change the way the normalization factor is computed. """ self._normalization_constant = 1.0 / self._normalization_correction if normalize: # compute normalization constant so that # N*C*sum(data) = 1: if self._img_norm is None: self._img_norm = self._compute_raw_image_norm() if self._img_norm != 0.0 and np.isfinite(self._img_norm): self._normalization_constant /= self._img_norm self._normalization_status = 0 else: self._normalization_constant = 1.0 self._normalization_status = 1 warnings.warn('Overflow encountered while computing ' 'normalization constant. Normalization ' 'constant will be set to 1.', AstropyUserWarning) else: self._normalization_status = 2 @property def oversampling(self): """ The factor by which the stored image is oversampled. An input to this model is multiplied by this factor to yield the index into the stored image. """ return self._oversampling @property def data(self): """ Get original image data. """ return self._data @property def normalized_data(self): """ Get normalized and/or intensity-corrected image data. """ return self._normalization_constant * self._data @property def normalization_constant(self): """ Get normalization constant. """ return self._normalization_constant @property def normalization_status(self): """ Get normalization status. Possible status values are: * 0: **Performed**. Model has been successfully normalized at user's request. * 1: **Failed**. Attempt to normalize has failed. * 2: **NotRequested**. User did not request model to be normalized. """ return self._normalization_status @property def normalization_correction(self): """ Set/Get flux correction factor. .. note:: When setting correction factor, model's flux will be adjusted accordingly such that if this model was a good fit to some target image before, then it will remain a good fit after correction factor change. """ return self._normalization_correction @normalization_correction.setter def normalization_correction(self, normalization_correction): old_cf = self._normalization_correction self._normalization_correction = normalization_correction self._compute_normalization(normalize=self._normalization_status != 2) # adjust model's flux so that if this model was a good fit to # some target image, then it will remain a good fit after # correction factor change: self.flux *= normalization_correction / old_cf @property def shape(self): """ A tuple of dimensions of the data array in numpy style (ny, nx). """ return self._shape @property def nx(self): """ Number of columns in the data array. """ return self._nx @property def ny(self): """ Number of rows in the data array. """ return self._ny @property def origin(self): """ A tuple of ``x`` and ``y`` coordinates of the origin of the coordinate system in terms of pixels of model's image. When setting the coordinate system origin, a tuple of two integers or floats may be used. If origin is set to `None`, the origin of the coordinate system will be set to the middle of the data array (``(npix-1)/2.0``). .. warning:: Modifying ``origin`` will not adjust (modify) model's parameters ``x_0`` and ``y_0``. """ return (self._x_origin, self._y_origin) @origin.setter def origin(self, origin): if origin is None: self._x_origin = (self._nx - 1) / 2.0 self._y_origin = (self._ny - 1) / 2.0 elif hasattr(origin, '__iter__') and len(origin) == 2: self._x_origin, self._y_origin = origin else: raise TypeError('Parameter "origin" must be either None or an ' 'iterable with two elements.') @property def x_origin(self): """ X-coordinate of the origin of the coordinate system. """ return self._x_origin @property def y_origin(self): """ Y-coordinate of the origin of the coordinate system. """ return self._y_origin @property def fill_value(self): """ Fill value to be returned for coordinates outside of the domain of definition of the interpolator. If ``fill_value`` is `None`, then values outside of the domain of definition are the ones returned by the interpolator. """ return self._fill_value @fill_value.setter def fill_value(self, fill_value): self._fill_value = fill_value def _store_interpolator_kwargs(self, **kwargs): """ Store interpolator keyword arguments. This function should be called in a subclass whenever model's interpolator is (re)computed. """ self._interpolator_kwargs = copy.deepcopy(kwargs) @property def interpolator_kwargs(self): """ Get current interpolator's arguments used when interpolator was created. """ return self._interpolator_kwargs def compute_interpolator(self, **kwargs): """ Compute/define the interpolating spline. This function can be overridden in a subclass to define custom interpolators. Parameters ---------- **kwargs : dict, optional Additional optional keyword arguments: * **degree** : int, tuple, optional Degree of the interpolating spline. A tuple can be used to provide different degrees for the X- and Y-axes. Default value is degree=3. * **s** : float, optional Non-negative smoothing factor. Default value s=0 corresponds to interpolation. See :py:class:`~scipy.interpolate.RectBivariateSpline` for more details. Notes ----- * When subclassing :py:class:`FittableImageModel` for the purpose of overriding :py:func:`compute_interpolator`, the :py:func:`evaluate` may need to overridden as well depending on the behavior of the new interpolator. In addition, for improved future compatibility, make sure that the overriding method stores keyword arguments ``kwargs`` by calling ``_store_interpolator_kwargs`` method. * Use caution when modifying interpolator's degree or smoothness in a computationally intensive part of the code as it may decrease code performance due to the need to recompute interpolator. """ if 'degree' in kwargs: degree = kwargs['degree'] if hasattr(degree, '__iter__') and len(degree) == 2: degx = int(degree[0]) degy = int(degree[1]) else: degx = int(degree) degy = int(degree) if degx < 0 or degy < 0: raise ValueError('Interpolator degree must be a non-negative ' 'integer') else: degx = 3 degy = 3 smoothness = kwargs.get('s', 0) x = np.arange(self._nx, dtype=float) y = np.arange(self._ny, dtype=float) self.interpolator = RectBivariateSpline( x, y, self._data.T, kx=degx, ky=degy, s=smoothness ) self._store_interpolator_kwargs(**kwargs) def evaluate(self, x, y, flux, x_0, y_0, *, use_oversampling=True): """ 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. 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. use_oversampling : bool, optional Whether to use the oversampling factor to calculate the model pixel indices. The default is `True`, which means the input indices will be multiplied by this factor. Returns ------- evaluated_model : `~numpy.ndarray` The evaluated model. """ if use_oversampling: xi = self._oversampling[1] * (np.asarray(x) - x_0) yi = self._oversampling[0] * (np.asarray(y) - y_0) else: xi = np.asarray(x) - x_0 yi = np.asarray(y) - y_0 xi = xi.astype(float) yi = yi.astype(float) xi += self._x_origin yi += self._y_origin f = flux * self._normalization_constant evaluated_model = f * self.interpolator.ev(xi, yi) if self._fill_value is not None: # find indices of pixels that are outside the input pixel grid and # set these pixels to the 'fill_value': invalid = (((xi < 0) | (xi > self._nx - 1)) | ((yi < 0) | (yi > self._ny - 1))) evaluated_model[invalid] = self._fill_value return evaluated_model class _LegacyEPSFModel(Fittable2DModel): """ This class will be removed when the deprecated EPSFModel is removed, which will require the EPSFBuilder class to be rewritten/refactored/replaced. A class that models an effective PSF (ePSF). The EPSFModel is normalized such that the sum of the PSF over the (undersampled) pixels within the input ``norm_radius`` is 1.0. This means that when the EPSF is fit to stars, the resulting flux corresponds to aperture photometry within a circular aperture of radius ``norm_radius``. While this class is a subclass of `FittableImageModel`, it is very similar. The primary differences/motivation are a few additional parameters necessary specifically for ePSFs. Parameters ---------- data : 2D `~numpy.ndarray` Array containing the 2D image. flux : float, optional Intensity scaling factor for image data. x_0, y_0 : float, optional Position of a feature in the image in the output coordinate grid on which the model is evaluated. normalize : bool, optional Indicates whether or not the model should be build on normalized input image data. normalization_correction : float, optional A strictly positive number that represents correction that needs to be applied to model's data normalization. origin : tuple, None, optional A reference point in the input image ``data`` array. When origin is `None`, origin will be set at the middle of the image array. oversampling : int or array_like (int) The integer oversampling factor(s) of the ePSF relative to the input ``stars`` along each axis. If ``oversampling`` is a scalar then it will be used for both axes. If ``oversampling`` has two elements, they must be in ``(y, x)`` order. fill_value : float, optional The value to be returned when evaluation is performed outside the domain of the model. norm_radius : float, optional The radius inside which the ePSF is normalized by the sum over undersampled integer pixel values inside a circular aperture. **kwargs : dict, optional Additional optional keyword arguments to be passed directly to the `compute_interpolator` method. See `compute_interpolator` for more details. """ flux = Parameter(description='Intensity scaling factor for image data.', default=1.0) x_0 = Parameter(description='X-position of a feature in the image in ' 'the output coordinate grid on which the model is ' 'evaluated.', default=0.0) y_0 = Parameter(description='Y-position of a feature in the image in ' 'the output coordinate grid on which the model is ' 'evaluated.', default=0.0) def __init__(self, data, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, normalize=False, normalization_correction=1.0, origin=None, oversampling=1, fill_value=0.0, norm_radius=5.5, **kwargs): self._norm_radius = norm_radius self._fill_value = fill_value self._img_norm = None self._normalization_status = 0 if normalize else 2 self._store_interpolator_kwargs(**kwargs) self._oversampling = as_pair('oversampling', oversampling, lower_bound=(0, 1)) if normalization_correction <= 0: raise ValueError("'normalization_correction' must be strictly " 'positive.') self._normalization_correction = normalization_correction self._normalization_constant = 1.0 / self._normalization_correction self._data = np.array(data, copy=True, dtype=float) if not np.all(np.isfinite(self._data)): raise ValueError("All elements of input 'data' must be finite.") # set input image related parameters: self._ny, self._nx = self._data.shape self._shape = self._data.shape if self._data.size < 1: raise ValueError('Image data array cannot be zero-sized.') # set the origin of the coordinate system in image's pixel grid: self.origin = origin flux = self._initial_norm(flux, normalize) super().__init__(flux, x_0, y_0) # initialize interpolator: self.compute_interpolator(**kwargs) def _initial_norm(self, flux, normalize): if flux is None: if self._img_norm is None: self._img_norm = self._compute_raw_image_norm() flux = self._img_norm if normalize: self._compute_normalization() else: self._img_norm = self._compute_raw_image_norm() return flux def _compute_raw_image_norm(self): """ Compute the normalization of input image data as the flux within a given radius. """ xypos = (self._nx / 2.0, self._ny / 2.0) # TODO: generalize "radius" (ellipse?) is oversampling is # different along x/y axes radius = self._norm_radius * self.oversampling[0] aper = CircularAperture(xypos, r=radius) flux, _ = aper.do_photometry(self._data, method='exact') return flux[0] / np.prod(self.oversampling) def _compute_normalization(self, normalize=True): """ Helper function that computes (corrected) normalization factor of the original image data. For the ePSF this is defined as the sum over the inner N (default=5.5) pixels of the non-oversampled image. Will renormalize the data to the value calculated. """ if normalize: if self._img_norm is None: if np.sum(self._data) == 0: self._img_norm = 1 else: self._img_norm = self._compute_raw_image_norm() if self._img_norm != 0.0 and np.isfinite(self._img_norm): self._data /= (self._img_norm * self._normalization_correction) self._normalization_status = 0 else: self._normalization_status = 1 self._img_norm = 1 warnings.warn('Overflow encountered while computing ' 'normalization constant. Normalization ' 'constant will be set to 1.', AstropyUserWarning) else: self._normalization_status = 2 @property def normalized_data(self): """ Overloaded dummy function that also returns self._data, as the normalization occurs within _compute_normalization in EPSFModel, and as such self._data will sum, accounting for under/oversampled pixels, to 1/self._normalization_correction. """ return self._data @property def oversampling(self): """ The factor by which the stored image is oversampled. An input to this model is multiplied by this factor to yield the index into the stored image. """ return self._oversampling @property def data(self): """ Get original image data. """ return self._data @property def normalization_constant(self): """ Get normalization constant. """ return self._normalization_constant @property def normalization_status(self): """ Get normalization status. Possible status values are: * 0: **Performed**. Model has been successfully normalized at user's request. * 1: **Failed**. Attempt to normalize has failed. * 2: **NotRequested**. User did not request model to be normalized. """ return self._normalization_status @property def normalization_correction(self): """ Set/Get flux correction factor. .. note:: When setting correction factor, model's flux will be adjusted accordingly such that if this model was a good fit to some target image before, then it will remain a good fit after correction factor change. """ return self._normalization_correction @normalization_correction.setter def normalization_correction(self, normalization_correction): old_cf = self._normalization_correction self._normalization_correction = normalization_correction self._compute_normalization(normalize=self._normalization_status != 2) # adjust model's flux so that if this model was a good fit to # some target image, then it will remain a good fit after # correction factor change: self.flux *= normalization_correction / old_cf @property def shape(self): """ A tuple of dimensions of the data array in numpy style (ny, nx). """ return self._shape @property def nx(self): """ Number of columns in the data array. """ return self._nx @property def ny(self): """ Number of rows in the data array. """ return self._ny @property def origin(self): """ A tuple of ``x`` and ``y`` coordinates of the origin of the coordinate system in terms of pixels of model's image. When setting the coordinate system origin, a tuple of two integers or floats may be used. If origin is set to `None`, the origin of the coordinate system will be set to the middle of the data array (``(npix-1)/2.0``). .. warning:: Modifying ``origin`` will not adjust (modify) model's parameters ``x_0`` and ``y_0``. """ return (self._x_origin, self._y_origin) @origin.setter def origin(self, origin): if origin is None: self._x_origin = (self._nx - 1) / 2.0 / self.oversampling[1] self._y_origin = (self._ny - 1) / 2.0 / self.oversampling[0] elif (hasattr(origin, '__iter__') and len(origin) == 2): self._x_origin, self._y_origin = origin else: raise TypeError('Parameter "origin" must be either None or an ' 'iterable with two elements.') @property def x_origin(self): """ X-coordinate of the origin of the coordinate system. """ return self._x_origin @property def y_origin(self): """ Y-coordinate of the origin of the coordinate system. """ return self._y_origin @property def fill_value(self): """ Fill value to be returned for coordinates outside of the domain of definition of the interpolator. If ``fill_value`` is `None`, then values outside of the domain of definition are the ones returned by the interpolator. """ return self._fill_value @fill_value.setter def fill_value(self, fill_value): self._fill_value = fill_value def _store_interpolator_kwargs(self, **kwargs): """ Store interpolator keyword arguments. This function should be called in a subclass whenever model's interpolator is (re)computed. """ self._interpolator_kwargs = copy.deepcopy(kwargs) @property def interpolator_kwargs(self): """ Get current interpolator's arguments used when interpolator was created. """ return self._interpolator_kwargs def compute_interpolator(self, **kwargs): """ Compute/define the interpolating spline. This function can be overridden in a subclass to define custom interpolators. Parameters ---------- **kwargs : dict, optional Additional optional keyword arguments: * **degree** : int, tuple, optional Degree of the interpolating spline. A tuple can be used to provide different degrees for the X- and Y-axes. Default value is degree=3. * **s** : float, optional Non-negative smoothing factor. Default value s=0 corresponds to interpolation. See :py:class:`~scipy.interpolate.RectBivariateSpline` for more details. Notes ----- * When subclassing :py:class:`FittableImageModel` for the purpose of overriding :py:func:`compute_interpolator`, the :py:func:`evaluate` may need to overridden as well depending on the behavior of the new interpolator. In addition, for improved future compatibility, make sure that the overriding method stores keyword arguments ``kwargs`` by calling ``_store_interpolator_kwargs`` method. * Use caution when modifying interpolator's degree or smoothness in a computationally intensive part of the code as it may decrease code performance due to the need to recompute interpolator. """ if 'degree' in kwargs: degree = kwargs['degree'] if hasattr(degree, '__iter__') and len(degree) == 2: degx = int(degree[0]) degy = int(degree[1]) else: degx = int(degree) degy = int(degree) if degx < 0 or degy < 0: raise ValueError('Interpolator degree must be a non-negative ' 'integer') else: degx = 3 degy = 3 smoothness = kwargs.get('s', 0) # Interpolator must be set to interpolate on the undersampled # pixel grid, going from 0 to len(undersampled_grid) x = np.arange(self._nx, dtype=float) / self.oversampling[1] y = np.arange(self._ny, dtype=float) / self.oversampling[0] self.interpolator = RectBivariateSpline( x, y, self._data.T, kx=degx, ky=degy, s=smoothness ) self._store_interpolator_kwargs(**kwargs) def evaluate(self, x, y, flux, x_0, y_0): """ 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. 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 ------- evaluated_model : `~numpy.ndarray` The evaluated model. """ xi = np.asarray(x) - x_0 + self._x_origin yi = np.asarray(y) - y_0 + self._y_origin evaluated_model = flux * self.interpolator.ev(xi, yi) if self._fill_value is not None: # find indices of pixels that are outside the input pixel # grid and set these pixels to the 'fill_value': invalid = (((xi < 0) | (xi > (self._nx - 1) / self.oversampling[1])) | ((yi < 0) | (yi > (self._ny - 1) / self.oversampling[0]))) evaluated_model[invalid] = self._fill_value return evaluated_model @deprecated('2.0.0', alternative='`ImagePSF`') class EPSFModel(_LegacyEPSFModel): """ A class that models an effective PSF (ePSF). The EPSFModel is normalized such that the sum of the PSF over the (undersampled) pixels within the input ``norm_radius`` is 1.0. This means that when the EPSF is fit to stars, the resulting flux corresponds to aperture photometry within a circular aperture of radius ``norm_radius``. While this class is a subclass of `FittableImageModel`, it is very similar. The primary differences/motivation are a few additional parameters necessary specifically for ePSFs. Parameters ---------- data : 2D `~numpy.ndarray` Array containing the 2D image. flux : float, optional Intensity scaling factor for image data. x_0, y_0 : float, optional Position of a feature in the image in the output coordinate grid on which the model is evaluated. normalize : bool, optional Indicates whether or not the model should be build on normalized input image data. normalization_correction : float, optional A strictly positive number that represents correction that needs to be applied to model's data normalization. origin : tuple, None, optional A reference point in the input image ``data`` array. When origin is `None`, origin will be set at the middle of the image array. oversampling : int or array_like (int) The integer oversampling factor(s) of the ePSF relative to the input ``stars`` along each axis. If ``oversampling`` is a scalar then it will be used for both axes. If ``oversampling`` has two elements, they must be in ``(y, x)`` order. fill_value : float, optional The value to be returned when evaluation is performed outside the domain of the model. norm_radius : float, optional The radius inside which the ePSF is normalized by the sum over undersampled integer pixel values inside a circular aperture. **kwargs : dict, optional Additional optional keyword arguments to be passed directly to the "compute_interpolator" method. See "compute_interpolator" for more details. """ flux = Parameter(description='Intensity scaling factor for image data.', default=1.0) x_0 = Parameter(description='X-position of a feature in the image in ' 'the output coordinate grid on which the model is ' 'evaluated.', default=0.0) y_0 = Parameter(description='Y-position of a feature in the image in ' 'the output coordinate grid on which the model is ' 'evaluated.', default=0.0) def __init__(self, data, *, flux=flux.default, x_0=x_0.default, y_0=y_0.default, normalize=True, normalization_correction=1.0, origin=None, oversampling=1, fill_value=0.0, norm_radius=5.5, **kwargs): super().__init__(data=data, flux=flux, x_0=x_0, y_0=y_0, normalize=normalize, normalization_correction=normalization_correction, origin=origin, oversampling=oversampling, fill_value=fill_value, norm_radius=norm_radius, **kwargs) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.7139268 photutils-2.2.0/photutils/psf/matching/0000755000175100001660000000000014755160634017601 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/matching/__init__.py0000644000175100001660000000036714755160622021715 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools to generate kernels for matching point spread functions. """ from .fourier import * # noqa: F401, F403 from .windows import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/matching/fourier.py0000644000175100001660000000651514755160622021632 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for matching PSFs using Fourier methods. """ import numpy as np from numpy.fft import fft2, fftshift, ifft2, ifftshift from scipy.ndimage import zoom __all__ = ['create_matching_kernel', 'resize_psf'] def resize_psf(psf, input_pixel_scale, output_pixel_scale, *, order=3): """ Resize a PSF using spline interpolation of the requested order. Parameters ---------- psf : 2D `~numpy.ndarray` The 2D data array of the PSF. input_pixel_scale : float The pixel scale of the input ``psf``. The units must match ``output_pixel_scale``. output_pixel_scale : float The pixel scale of the output ``psf``. The units must match ``input_pixel_scale``. order : float, optional The order of the spline interpolation (0-5). The default is 3. Returns ------- result : 2D `~numpy.ndarray` The resampled/interpolated 2D data array. """ ratio = input_pixel_scale / output_pixel_scale return zoom(psf, ratio, order=order) / ratio**2 def create_matching_kernel(source_psf, target_psf, *, window=None): """ Create a kernel to match 2D point spread functions (PSF) using the ratio of Fourier transforms. Parameters ---------- source_psf : 2D `~numpy.ndarray` The source PSF. The source PSF should have higher resolution (i.e., narrower) than the target PSF. ``source_psf`` and ``target_psf`` must have the same shape and pixel scale. target_psf : 2D `~numpy.ndarray` The target PSF. The target PSF should have lower resolution (i.e., broader) than the source PSF. ``source_psf`` and ``target_psf`` must have the same shape and pixel scale. window : callable, optional The window (or taper) function or callable class instance used to remove high frequency noise from the PSF matching kernel. Some examples include: * `~photutils.psf.matching.HanningWindow` * `~photutils.psf.matching.TukeyWindow` * `~photutils.psf.matching.CosineBellWindow` * `~photutils.psf.matching.SplitCosineBellWindow` * `~photutils.psf.matching.TopHatWindow` For more information on window functions and example usage, see :ref:`psf_matching`. Returns ------- kernel : 2D `~numpy.ndarray` The matching kernel to go from ``source_psf`` to ``target_psf``. The output matching kernel is normalized such that it sums to 1. """ # inputs are copied so that they are not changed when normalizing source_psf = np.copy(np.asanyarray(source_psf)) target_psf = np.copy(np.asanyarray(target_psf)) if source_psf.shape != target_psf.shape: raise ValueError('source_psf and target_psf must have the same shape ' '(i.e., registered with the same pixel scale).') # ensure input PSFs are normalized source_psf /= source_psf.sum() target_psf /= target_psf.sum() source_otf = fftshift(fft2(source_psf)) target_otf = fftshift(fft2(target_psf)) ratio = target_otf / source_otf # apply a window function in frequency space if window is not None: ratio *= window(target_psf.shape) kernel = np.real(fftshift(ifft2(ifftshift(ratio)))) return kernel / kernel.sum() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.7139268 photutils-2.2.0/photutils/psf/matching/tests/0000755000175100001660000000000014755160634020743 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/matching/tests/__init__.py0000644000175100001660000000000014755160622023037 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/matching/tests/test_fourier.py0000644000175100001660000000273114755160622024027 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the fourier module. """ import numpy as np import pytest from astropy.modeling.fitting import TRFLSQFitter from astropy.modeling.models import Gaussian2D from numpy.testing import assert_allclose from photutils.psf.matching.fourier import create_matching_kernel, resize_psf from photutils.psf.matching.windows import SplitCosineBellWindow def test_resize_psf(): psf1 = np.ones((5, 5)) psf2 = resize_psf(psf1, 0.1, 0.05) assert psf2.shape == (10, 10) def test_create_matching_kernel(): """ Test with noiseless 2D Gaussians. """ size = 25 cen = (size - 1) / 2.0 y, x = np.mgrid[0:size, 0:size] std1 = 3.0 std2 = 5.0 gm1 = Gaussian2D(1.0, cen, cen, std1, std1) gm2 = Gaussian2D(1.0, cen, cen, std2, std2) g1 = gm1(x, y) g2 = gm2(x, y) g1 /= g1.sum() g2 /= g2.sum() window = SplitCosineBellWindow(0.0, 0.2) k = create_matching_kernel(g1, g2, window=window) fitter = TRFLSQFitter() gfit = fitter(gm1, x, y, k) assert_allclose(gfit.x_stddev, gfit.y_stddev) assert_allclose(gfit.x_stddev, np.sqrt(std2**2 - std1**2), 0.06) def test_create_matching_kernel_shapes(): """ Test with wrong PSF shapes. """ psf1 = np.ones((5, 5)) psf2 = np.ones((3, 3)) match = 'source_psf and target_psf must have the same shape' with pytest.raises(ValueError, match=match): create_matching_kernel(psf1, psf2) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/matching/tests/test_windows.py0000644000175100001660000000376314755160622024054 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the windows module. """ import numpy as np import pytest from numpy.testing import assert_allclose from scipy.signal.windows import tukey from photutils.psf.matching.windows import (CosineBellWindow, HanningWindow, SplitCosineBellWindow, TopHatWindow, TukeyWindow) def test_hanning(): win = HanningWindow() data = win((5, 5)) ref = [0.0, 0.19715007, 0.5, 0.19715007, 0.0] assert_allclose(data[1, :], ref) def test_hanning_numpy(): """ Test Hanning window against 1D numpy version. """ size = 101 cen = (size - 1) // 2 shape = (size, size) win = HanningWindow() data = win(shape) ref1d = np.hanning(shape[0]) assert_allclose(data[cen, :], ref1d) def test_tukey(): win = TukeyWindow(0.5) data = win((5, 5)) ref = [0.0, 0.63312767, 1.0, 0.63312767, 0.0] assert_allclose(data[1, :], ref) def test_tukey_scipy(): """ Test Tukey window against 1D scipy version. """ size = 101 cen = (size - 1) // 2 shape = (size, size) alpha = 0.4 win = TukeyWindow(alpha=alpha) data = win(shape) ref1d = tukey(shape[0], alpha=alpha) assert_allclose(data[cen, :], ref1d) def test_cosine_bell(): win = CosineBellWindow(alpha=0.8) data = win((7, 7)) ref = [0.0, 0.0, 0.19715007, 0.5, 0.19715007, 0.0, 0.0] assert_allclose(data[2, :], ref) def test_split_cosine_bell(): win = SplitCosineBellWindow(alpha=0.8, beta=0.2) data = win((5, 5)) ref = [0.0, 0.3454915, 1.0, 0.3454915, 0.0] assert_allclose(data[2, :], ref) def test_tophat(): win = TopHatWindow(beta=0.5) data = win((5, 5)) ref = [0.0, 1.0, 1.0, 1.0, 0.0] assert_allclose(data[2, :], ref) def test_invalid_shape(): win = HanningWindow() match = 'shape must have only 2 elements' with pytest.raises(ValueError, match=match): win((5,)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/matching/windows.py0000644000175100001660000001527314755160622021652 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides window (or tapering) functions for matching PSFs using Fourier methods. """ import numpy as np __all__ = [ 'CosineBellWindow', 'HanningWindow', 'SplitCosineBellWindow', 'TopHatWindow', 'TukeyWindow', ] def _radial_distance(shape): """ Return an array where each value is the Euclidean distance from the array center. Parameters ---------- shape : tuple of int The size of the output array along each axis. Returns ------- result : 2D `~numpy.ndarray` An array containing the Euclidean radial distances from the array center. """ if len(shape) != 2: raise ValueError('shape must have only 2 elements') position = (np.asarray(shape) - 1) / 2.0 x = np.arange(shape[1]) - position[1] y = np.arange(shape[0]) - position[0] xx, yy = np.meshgrid(x, y) return np.sqrt(xx**2 + yy**2) class SplitCosineBellWindow: """ Class to define a 2D split cosine bell taper function. Parameters ---------- alpha : float, optional The percentage of array values that are tapered. beta : float, optional The inner diameter as a fraction of the array size beyond which the taper begins. ``beta`` must be less or equal to 1.0. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf.matching import SplitCosineBellWindow taper = SplitCosineBellWindow(alpha=0.4, beta=0.3) data = taper((101, 101)) plt.imshow(data, origin='lower') plt.colorbar() 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)) plt.plot(data[50, :]) """ def __init__(self, alpha, beta): self.alpha = alpha self.beta = beta def __call__(self, shape): """ Call self as a function to return a 2D window function of the given shape. Parameters ---------- shape : tuple of int The size of the output array along each axis. Returns ------- result : 2D `~numpy.ndarray` The window function as a 2D array. """ radial_dist = _radial_distance(shape) npts = (np.array(shape).min() - 1.0) / 2.0 r_inner = self.beta * npts r = radial_dist - r_inner r_taper = int(np.floor(self.alpha * npts)) if r_taper != 0: f = 0.5 * (1.0 + np.cos(np.pi * r / r_taper)) else: f = np.ones(shape) f[radial_dist < r_inner] = 1.0 r_cut = r_inner + r_taper f[radial_dist > r_cut] = 0.0 return f class HanningWindow(SplitCosineBellWindow): """ Class to define a 2D `Hanning (or Hann) window `_ function. The Hann window is a taper formed by using a raised cosine with ends that touch zero. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf.matching import HanningWindow taper = HanningWindow() data = taper((101, 101)) plt.imshow(data, origin='lower') plt.colorbar() 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)) plt.plot(data[50, :]) """ def __init__(self): super().__init__(alpha=1.0, beta=0.0) class TukeyWindow(SplitCosineBellWindow): """ Class to define a 2D `Tukey window `_ function. The Tukey window is a taper formed by using a split cosine bell function with ends that touch zero. Parameters ---------- alpha : float, optional The percentage of array values that are tapered. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf.matching import TukeyWindow taper = TukeyWindow(alpha=0.4) data = taper((101, 101)) plt.imshow(data, origin='lower') plt.colorbar() 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)) plt.plot(data[50, :]) """ def __init__(self, alpha): super().__init__(alpha=alpha, beta=1.0 - alpha) class CosineBellWindow(SplitCosineBellWindow): """ Class to define a 2D cosine bell window function. Parameters ---------- alpha : float, optional The percentage of array values that are tapered. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf.matching import CosineBellWindow taper = CosineBellWindow(alpha=0.3) data = taper((101, 101)) plt.imshow(data, origin='lower') plt.colorbar() 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)) plt.plot(data[50, :]) """ def __init__(self, alpha): super().__init__(alpha=alpha, beta=0.0) class TopHatWindow(SplitCosineBellWindow): """ Class to define a 2D top hat window function. Parameters ---------- beta : float, optional The inner diameter as a fraction of the array size beyond which the taper begins. ``beta`` must be less or equal to 1.0. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf.matching import TopHatWindow taper = TopHatWindow(beta=0.4) data = taper((101, 101)) plt.imshow(data, origin='lower', interpolation='nearest') plt.colorbar() 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)) plt.plot(data[50, :]) """ def __init__(self, beta): super().__init__(alpha=0.0, beta=beta) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/model_helpers.py0000644000175100001660000005477314755160622021220 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides helper utilities for making PSF models. """ import contextlib import re import numpy as np from astropy.modeling import CompoundModel, Fittable2DModel, Parameter 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 __all__ = ['PRFAdapter', 'grid_from_epsfs', 'make_psf_model'] def make_psf_model(model, *, x_name=None, y_name=None, flux_name=None, normalize=True, dx=50, dy=50, subsample=100, use_dblquad=False): """ Make a PSF model that can be used with the PSF photometry classes (`PSFPhotometry` or `IterativePSFPhotometry`) from an Astropy fittable 2D model. If the ``x_name``, ``y_name``, or ``flux_name`` keywords are input, this function will map those ``model`` parameter names to ``x_0``, ``y_0``, or ``flux``, respectively. If any of the ``x_name``, ``y_name``, or ``flux_name`` keywords are `None`, then a new parameter will be added to the model corresponding to the missing parameter. Any new position parameters will be set to a default value of 0, and any new flux parameter will be set to a default value of 1. The output PSF model will have ``x_name``, ``y_name``, and ``flux_name`` attributes that contain the name of the corresponding model parameter. .. note:: This function is needed only in cases where the 2D PSF model does not have ``x_0``, ``y_0``, and ``flux`` parameters. It is *not* needed for any of the PSF models provided by Photutils. Parameters ---------- model : `~astropy.modeling.Fittable2DModel` An Astropy fittable 2D model to use as a PSF. x_name : `str` or `None`, optional The name of the ``model`` parameter that corresponds to the x center of the PSF. If `None`, the model will be assumed to be centered at x=0, and a new model parameter called ``xpos_0`` will be added for the x position. y_name : `str` or `None`, optional The name of the ``model`` parameter that corresponds to the y center of the PSF. If `None`, the model will be assumed to be centered at y=0, and a new parameter called ``ypos_1`` will be added for the y position. flux_name : `str` or `None`, optional The name of the ``model`` parameter that corresponds to the total flux of a source. If `None`, a new model parameter called ``flux_3`` will be added for model flux. normalize : bool, optional If `True`, the input ``model`` will be integrated and rescaled so that its sum integrates to 1. This normalization occurs only once for the input ``model``. If the total flux of ``model`` somehow depends on (x, y) position, then one will need to correct the fitted model fluxes for this effect. dx, dy : odd int, optional The size of the integration grid in x and y for normalization. Must be odd. These keywords are ignored if ``normalize`` is `False` or ``use_dblquad`` is `True`. subsample : int, optional The subsampling factor for the integration grid along each axis for normalization. Each pixel will be sampled ``subsample`` x ``subsample`` times. This keyword is ignored if ``normalize`` is `False` or ``use_dblquad`` is `True`. use_dblquad : bool, optional If `True`, then use `scipy.integrate.dblquad` to integrate the model for normalization. This is *much* slower than the default integration of the evaluated model, but it is more accurate. This keyword is ignored if ``normalize`` is `False`. Returns ------- result : `~astropy.modeling.CompoundModel` A PSF model that can be used with the PSF photometry classes. The returned model will always be an Astropy compound model. Notes ----- To normalize the model, by default it is discretized on a grid of size ``dx`` x ``dy`` from the model center with a subsampling factor of ``subsample``. The model is then integrated over the grid using trapezoidal integration. If the ``use_dblquad`` keyword is set to `True`, then the model is integrated using `scipy.integrate.dblquad`. This is *much* slower than the default integration of the evaluated model, but it is more accurate. Also, note that the ``dblquad`` integration can sometimes fail, e.g., return zero for a non-zero model. This can happen when the model function is sharply localized relative to the size of the integration interval. Examples -------- >>> from astropy.modeling.models import Gaussian2D >>> from photutils.psf import make_psf_model >>> model = Gaussian2D(x_stddev=2, y_stddev=2) >>> psf_model = make_psf_model(model, x_name='x_mean', y_name='y_mean') >>> print(psf_model.param_names) # doctest: +SKIP ('amplitude_2', 'x_mean_2', 'y_mean_2', 'x_stddev_2', 'y_stddev_2', 'theta_2', 'amplitude_3', 'amplitude_4') """ input_model = model.copy() if x_name is None: x_model = _InverseShift(0, name='x_position') # "offset" is the _InverseShift parameter name; # the x inverse shift model is always the first submodel x_name = 'offset_0' else: if x_name not in input_model.param_names: raise ValueError(f'{x_name!r} parameter name not found in the ' 'input model.') x_model = Identity(1) x_name = _shift_model_param(input_model, x_name, shift=2) if y_name is None: y_model = _InverseShift(0, name='y_position') # "offset" is the _InverseShift parameter name; # the y inverse shift model is always the second submodel y_name = 'offset_1' else: if y_name not in input_model.param_names: raise ValueError(f'{y_name!r} parameter name not found in the ' 'input model.') y_model = Identity(1) y_name = _shift_model_param(input_model, y_name, shift=2) x_model.fittable = True y_model.fittable = True psf_model = (x_model & y_model) | input_model if flux_name is None: psf_model *= Const2D(1.0, name='flux') # "amplitude" is the Const2D parameter name; # the flux scaling is always the last component flux_name = psf_model.param_names[-1] else: flux_name = _shift_model_param(input_model, flux_name, shift=2) if normalize: integral = _integrate_model(psf_model, x_name=x_name, y_name=y_name, dx=dx, dy=dy, subsample=subsample, use_dblquad=use_dblquad) if integral == 0: raise ValueError('Cannot normalize the model because the ' 'integrated flux is zero.') psf_model *= Const2D(1.0 / integral, name='normalization_scaling') # fix all the output model parameters that are not x, y, or flux for name in psf_model.param_names: psf_model.fixed[name] = name not in (x_name, y_name, flux_name) # final check that the x, y, and flux parameter names are in the # output model names = (x_name, y_name, flux_name) for name in names: if name not in psf_model.param_names: raise ValueError(f'{name!r} parameter name not found in the ' 'output model.') # set the parameter names for the PSF photometry classes psf_model.x_name = x_name psf_model.y_name = y_name psf_model.flux_name = flux_name # set aliases psf_model.x_0 = getattr(psf_model, x_name) psf_model.y_0 = getattr(psf_model, y_name) psf_model.flux = getattr(psf_model, flux_name) return psf_model class _InverseShift(Shift): """ A model that is the inverse of the normal `astropy.modeling.functional_models.Shift` model. """ @staticmethod def evaluate(x, offset): return x - offset @staticmethod def fit_deriv(x, offset): """ One dimensional Shift model derivative with respect to parameter. """ d_offset = -np.ones_like(x) + offset * 0.0 return [d_offset] def _integrate_model(model, x_name=None, y_name=None, dx=50, dy=50, subsample=100, use_dblquad=False): """ Integrate a model over a 2D grid. By default, the model is discretized on a grid of size ``dx`` x ``dy`` from the model center with a subsampling factor of ``subsample``. The model is then integrated over the grid using trapezoidal integration. If the ``use_dblquad`` keyword is set to `True`, then the model is integrated using `scipy.integrate.dblquad`. This is *much* slower than the default integration of the evaluated model, but it is more accurate. Also, note that the ``dblquad`` integration can sometimes fail, e.g., return zero for a non-zero model. This can happen when the model function is sharply localized relative to the size of the integration interval. Parameters ---------- model : `~astropy.modeling.Fittable2DModel` The Astropy 2D model. x_name : str or `None`, optional The name of the ``model`` parameter that corresponds to the x-axis center of the PSF. This parameter is required if ``use_dblquad`` is `False` and ignored if ``use_dblquad`` is `True`. y_name : str or `None`, optional The name of the ``model`` parameter that corresponds to the y-axis center of the PSF. This parameter is required if ``use_dblquad`` is `False` and ignored if ``use_dblquad`` is `True`. dx, dy : odd int, optional The size of the integration grid in x and y. Must be odd. These keywords are ignored if ``use_dblquad`` is `True`. subsample : int, optional The subsampling factor for the integration grid along each axis. Each pixel will be sampled ``subsample`` x ``subsample`` times. This keyword is ignored if ``use_dblquad`` is `True`. use_dblquad : bool, optional If `True`, then use `scipy.integrate.dblquad` to integrate the model. This is *much* slower than the default integration of the evaluated model, but it is more accurate. Returns ------- integral : float The integral of the model over the 2D grid. """ if use_dblquad: return dblquad(model, -np.inf, np.inf, -np.inf, np.inf)[0] if dx <= 0 or dy <= 0: raise ValueError('dx and dy must be > 0') if subsample < 1: raise ValueError('subsample must be >= 1') xc = getattr(model, x_name) yc = getattr(model, y_name) if np.any(~np.isfinite((xc.value, yc.value))): raise ValueError('model x and y positions must be finite') hx = (dx - 1) / 2 hy = (dy - 1) / 2 nxpts = int(dx * subsample) nypts = int(dy * subsample) xvals = np.linspace(xc - hx, xc + hx, nxpts) yvals = np.linspace(yc - hy, yc + hy, nypts) # evaluate the model on the subsampled grid data = model(xvals.reshape(-1, 1), yvals.reshape(1, -1)) if isinstance(data, Quantity): data = data.value # now integrate over the subsampled grid (first over x, then over y) int_func = trapezoid return int_func([int_func(row, xvals) for row in data], yvals) def _shift_model_param(model, param_name, shift=2): if isinstance(model, CompoundModel): # for CompoundModel, add "shift" to the parameter suffix out = re.search(r'(.*)_([\d]*)$', param_name) new_name = out.groups()[0] + '_' + str(int(out.groups()[1]) + 2) else: # simply add the shift to the parameter name new_name = param_name + '_' + str(shift) return new_name def grid_from_epsfs(epsfs, grid_xypos=None, meta=None): """ Create a GriddedPSFModel from a list of ImagePSF models. Given a list of `~photutils.psf.ImagePSF` models, this function will return a `~photutils.psf.GriddedPSFModel`. The fiducial points for each input ImagePSF can either be set on each individual model by setting the 'x_0' and 'y_0' attributes, or provided as a list of tuples (``grid_xypos``). If a ``grid_xypos`` list is provided, it must match the length of input EPSFs. In either case, the fiducial points must be on a grid. Optionally, a ``meta`` dictionary may be provided for the output GriddedPSFModel. If this dictionary contains the keys 'grid_xypos', 'oversampling', or 'fill_value', they will be overridden. Note: If set on the input ImagePSF (x_0, y_0), then ``origin`` must be the same for each input EPSF. Additionally data units and dimensions must be for each input EPSF, and values for ``flux`` and ``oversampling``, and ``fill_value`` must match as well. Parameters ---------- epsfs : list of `photutils.psf.ImagePSF` A list of ImagePSF models representing the individual PSFs. grid_xypos : list, optional A list of fiducial points (x_0, y_0) for each PSF. If not provided, the x_0 and y_0 of each input EPSF will be considered the fiducial point for that PSF. Default is None. meta : dict, optional Additional metadata for the GriddedPSFModel. Note that, if they exist in the supplied ``meta``, any values under the keys ``grid_xypos`` , ``oversampling``, or ``fill_value`` will be overridden. Default is None. Returns ------- GriddedPSFModel: `photutils.psf.GriddedPSFModel` The gridded PSF model created from the input EPSFs. """ # prevent circular imports from photutils.psf import GriddedPSFModel, ImagePSF # optional, to store fiducial from input if `grid_xypos` is None x_0s = [] y_0s = [] data_arrs = [] oversampling = None fill_value = None dat_unit = None origin = None flux = None # make sure, if provided, that ``grid_xypos`` is the same length as # ``epsfs`` if grid_xypos is not None and len(grid_xypos) != len(epsfs): raise ValueError('``grid_xypos`` must be the same length as ' '``epsfs``.') # loop over input once for i, epsf in enumerate(epsfs): # check input type if not isinstance(epsf, ImagePSF): raise TypeError('All input `epsfs` must be of type ImagePSF') # 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): raise ValueError('All input ImagePSF models must have the ' 'same value for ``oversampling``.') if epsf.fill_value != fill_value: raise ValueError('All input ImagePSF models must have the ' 'same value for ``fill_value``.') if epsf.data.ndim != data_arrs[0].ndim: raise ValueError('All input ImagePSF models must have data ' 'with the same dimensions.') try: unitt = epsf.data_unit if unitt != dat_unit: raise ValueError('All input data must have the same unit.') except AttributeError as exc: if dat_unit is not None: raise ValueError('All input data must have the same ' 'unit.') from exc if epsf.flux != flux: raise ValueError('All input ImagePSF models must have the ' 'same value for ``flux``.') if grid_xypos is None: # get gridxy_pos from x_0, y_0 if not provided x_0s.append(epsf.x_0.value) y_0s.append(epsf.y_0.value) # also check that origin is the same, if using x_0s and y_0s # from input if np.all(epsf.origin != origin): raise ValueError('If using ``x_0``, ``y_0`` as fiducial point,' '``origin`` must match for each input EPSF.') # if not supplied, use from x_0, y_0 of input EPSFs as fiducuals # these are checked when GriddedPSFModel is created to make sure they # are actually on a grid. if grid_xypos is None: grid_xypos = list(zip(x_0s, y_0s, 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) @deprecated('2.0.0', alternative='a FittableImageModel derived from the ' 'discretize_model function in astropy.convolution') class PRFAdapter(Fittable2DModel): """ A model that adapts a supplied PSF model to act as a PRF. It integrates the PSF model over pixel "boxes". A critical built-in assumption is that the PSF model scale and location parameters are in *pixel* units. Parameters ---------- psfmodel : a 2D model The model to assume as representative of the PSF. renormalize_psf : bool, optional If True, the model will be integrated from -inf to inf and rescaled so that the total integrates to 1. Note that this renormalization only occurs *once*, so if the total flux of ``psfmodel`` depends on position, this will *not* be correct. flux : float, optional The total flux of the star. x_0 : float, optional The x position of the star. y_0 : float, optional The y position of the star. xname : str or None, optional The name of the ``psfmodel`` parameter that corresponds to the x-axis center of the PSF. If None, the model will be assumed to be centered at x=0. yname : str or None, optional The name of the ``psfmodel`` parameter that corresponds to the y-axis center of the PSF. If None, the model will be assumed to be centered at y=0. fluxname : str or None, optional The name of the ``psfmodel`` parameter that corresponds to the total flux of the star. If None, a scaling factor will be applied by the ``PRFAdapter`` instead of modifying the ``psfmodel``. **kwargs : dict, optional Additional optional keyword arguments to be passed to the `astropy.modeling.Model` parent class. Notes ----- This current implementation of this class (using numerical integration for each pixel) is extremely slow, and only suited for experimentation over relatively few small regions. It should be used only when absolutely necessary. If a model class of this type is needed, it is strongly recommended that you create a custom PRF model instead. If one needs a PRF model from an analytical PSF model, a more efficient option is to discretize the model on a grid using :func:`~astropy.convolution.discretize_model` using the ``'oversample'`` or ``'integrate'`` ``mode``. The resulting 2D image can then be used as the input to ``FittableImageModel`` to create an ePSF model. This will be *much* faster than using this class. """ flux = Parameter(default=1) x_0 = Parameter(default=0) y_0 = Parameter(default=0) def __init__(self, psfmodel, *, renormalize_psf=True, flux=flux.default, x_0=x_0.default, y_0=y_0.default, xname=None, yname=None, fluxname=None, **kwargs): self.psfmodel = psfmodel.copy() if renormalize_psf: self._psf_scale_factor = 1.0 / dblquad(self.psfmodel, -np.inf, np.inf, lambda x: -np.inf, lambda x: np.inf)[0] else: self._psf_scale_factor = 1 self.xname = xname self.yname = yname self.fluxname = fluxname # these can be used to adjust the integration behavior. Might be # used in the future to expose how the integration happens self._dblquadkwargs = {} super().__init__(n_models=1, x_0=x_0, y_0=y_0, flux=flux, **kwargs) def evaluate(self, x, y, flux, x_0, y_0): """ The evaluation function for PRFAdapter. Parameters ---------- x, y : float or array_like The coordinates at which to evaluate the model. flux : float The total flux of the star. x_0, y_0 : float The position of the star. Returns ------- evaluated_model : `~numpy.ndarray` The evaluated model. """ if not np.isscalar(flux): flux = flux[0] if not np.isscalar(x_0): x_0 = x_0[0] if not np.isscalar(y_0): y_0 = y_0[0] if self.xname is None: dx = x - x_0 else: dx = x setattr(self.psfmodel, self.xname, x_0) if self.xname is None: dy = y - y_0 else: dy = y setattr(self.psfmodel, self.yname, y_0) if self.fluxname is None: return (flux * self._psf_scale_factor * self._integrated_psfmodel(dx, dy)) setattr(self.psfmodel, self.yname, flux * self._psf_scale_factor) return self._integrated_psfmodel(dx, dy) def _integrated_psfmodel(self, dx, dy): # infer type/shape from the PSF model. Seems wasteful, but the # integration step is a *lot* more expensive so its just peanuts out = np.empty_like(self.psfmodel(dx, dy)) outravel = out.ravel() for i, (xi, yi) in enumerate(zip(dx.ravel(), dy.ravel(), strict=True)): outravel[i] = dblquad(self.psfmodel, xi - 0.5, xi + 0.5, lambda x: yi - 0.5, lambda x: yi + 0.5, **self._dblquadkwargs)[0] return out ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/model_io.py0000644000175100001660000004721614755160622020157 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines tools for reading and writing PSF models. """ import io import itertools import os import warnings import numpy as np from astropy.io import fits, registry from astropy.io.fits.verify import VerifyWarning from astropy.nddata import NDData, reshape_as_blocks __all__ = ['GriddedPSFModelRead', 'stdpsf_reader', 'webbpsf_reader'] __doctest_skip__ = ['GriddedPSFModelRead'] class GriddedPSFModelRead(registry.UnifiedReadWrite): """ Read and parse a FITS file into a `GriddedPSFModel` instance. This class enables the astropy unified I/O layer for `~photutils.psf.GriddedPSFModel`. This allows easily reading a file in different supported data formats using syntax such as:: >>> from photutils.psf import GriddedPSFModel >>> psf_model = GriddedPSFModel.read('filename.fits', format=format) Get help on the available readers for `~photutils.psf.GriddedPSFModel` using the ``help()`` method:: >>> # Get help reading Table and list supported formats >>> GriddedPSFModel.read.help() >>> # Get detailed help on the STSPSF FITS reader >>> GriddedPSFModel.read.help('stdpsf') >>> # Get detailed help on the WebbPSF FITS reader >>> GriddedPSFModel.read.help('webbpsf') >>> # Print list of available formats >>> GriddedPSFModel.read.list_formats() Parameters ---------- instance : object Descriptor calling instance or `None` if no instance. cls : type Descriptor calling class (either owner class or instance class). """ def __init__(self, instance, cls): # uses default global registry super().__init__(instance, cls, 'read', registry=None) def __call__(self, *args, **kwargs): """ Read and parse a FITS file into a `GriddedPSFModel` instance using the registered "read" function. Parameters ---------- *args : tuple Positional arguments passed through to data reader. The first argument is typically the input filename. **kwargs : dict, optional Keyword arguments passed through to data reader. This includes the ``format`` keyword argument. Returns ------- out : `~photutils.psf.GriddedPSFModel` A gridded ePSF model corresponding to FITS file contents. """ return self.registry.read(self._cls, *args, **kwargs) def _read_stdpsf(filename): """ Read a STScI standard-format ePSF (STDPSF) FITS file. Parameters ---------- filename : str The name of the STDPDF FITS file. Returns ------- data : dict A dictionary containing the ePSF data and metadata. """ with warnings.catch_warnings(): warnings.simplefilter('ignore', VerifyWarning) with fits.open(filename, ignore_missing_end=True) as hdulist: header = hdulist[0].header data = hdulist[0].data try: npsfs = header['NAXIS3'] nxpsfs = header['NXPSFS'] nypsfs = header['NYPSFS'] except KeyError as exc: raise ValueError('Invalid STDPDF FITS file.') from exc if 'IPSFX01' in header: xgrid = [header[f'IPSFX{i:02d}'] for i in range(1, nxpsfs + 1)] ygrid = [header[f'JPSFY{i:02d}'] for i in range(1, nypsfs + 1)] elif 'IPSFXA5' in header: xgrid = [] ygrid = [] xkeys = ('IPSFXA5', 'IPSFXB5', 'IPSFXC5', 'IPSFXD5') for xkey in xkeys: xgrid.extend([int(n) for n in header[xkey].split()]) ykeys = ('JPSFYA5', 'JPSFYB5') for ykey in ykeys: ygrid.extend([int(n) for n in header[ykey].split()]) else: raise ValueError('Unknown STDPSF FITS file.') # STDPDF FITS positions are 1-indexed xgrid = np.array(xgrid) - 1 ygrid = np.array(ygrid) - 1 # nypsfs, nxpsfs, detector # 6, 6 WFPC2, 4 det # 1, 1 ACS/HRC # 10, 9 ACS/WFC, 2 det # 3, 3 WFC3/IR # 8, 7 WFC3/UVIS, 2 det # 5, 5 NIRISS # 5, 5 NIRCam SW # 10, 20 NIRCam SW (NRCSW), 8 det # 5, 5 NIRCam LW # 3, 3 MIRI return {'data': data, 'npsfs': npsfs, 'nxpsfs': nxpsfs, 'nypsfs': nypsfs, 'xgrid': xgrid, 'ygrid': ygrid} def _split_detectors(grid_data, detector_data, detector_id): """ Split an ePSF array into individual detectors. Parameters ---------- grid_data : dict A dictionary containing the ePSF data and metadata. detector_data : dict A dictionary containing the detector data. detector_id : int The detector ID. Returns ------- data : `~numpy.ndarray` The ePSF data for the specified detector. xgrid : `~numpy.ndarray` The x-grid for the specified detector. ygrid : `~numpy.ndarray` The y-grid for the specified detector. Notes ----- In particular:: * HST WFPC2 STDPSF file contains 4 detectors * HST ACS/WFC STDPSF file contains 2 detectors * HST WFC3/UVIS STDPSF file contains 2 detectors * JWST NIRCam "NRCSW" STDPSF file contains 8 detectors """ data = grid_data['data'] npsfs = grid_data['npsfs'] nxpsfs = grid_data['nxpsfs'] nypsfs = grid_data['nypsfs'] xgrid = grid_data['xgrid'] ygrid = grid_data['ygrid'] nxdet = detector_data['nxdet'] nydet = detector_data['nydet'] det_map = detector_data['det_map'] det_size = detector_data['det_size'] ii = np.arange(npsfs).reshape((nypsfs, nxpsfs)) nxpsfs //= nxdet nypsfs //= nydet ndet = nxdet * nydet ii = reshape_as_blocks(ii, (nypsfs, nxpsfs)) ii = ii.reshape(ndet, npsfs // ndet) # detector_id -> index det_idx = det_map[detector_id] idx = ii[det_idx] data = data[idx] xp = det_idx % nxdet i0 = xp * nxpsfs i1 = i0 + nxpsfs xgrid = xgrid[i0:i1] - xp * det_size ygrid = ygrid[:nypsfs] if det_idx < nxdet else ygrid[nypsfs:] - det_size return data, xgrid, ygrid def _split_wfc_uvis(grid_data, detector_id): """ Split an ePSF array into individual WFC/UVIS detectors. Parameters ---------- grid_data : dict A dictionary containing the ePSF data and metadata. detector_id : int The detector ID. Returns ------- data : `~numpy.ndarray` The ePSF data for the specified detector. xgrid : `~numpy.ndarray` The x-grid for the specified detector. ygrid : `~numpy.ndarray` The y-grid for the specified detector. """ if detector_id is None: raise ValueError('detector_id must be specified for ACS/WFC and ' 'WFC3/UVIS ePSFs.') if detector_id not in (1, 2): raise ValueError('detector_id must be 1 or 2.') # ACS/WFC1 and WFC3/UVIS1 chip1 (sci, 2) are above chip2 (sci, 1) # in y-pixel coordinates xgrid = grid_data['xgrid'] ygrid = grid_data['ygrid'] ygrid = ygrid.reshape((2, ygrid.shape[0] // 2))[detector_id - 1] if detector_id == 2: ygrid -= 2048 npsfs = grid_data['npsfs'] data = grid_data['data'] data_ny, data_nx = data.shape[1:] data = data.reshape((2, npsfs // 2, data_ny, data_nx))[detector_id - 1] return data, xgrid, ygrid def _split_wfpc2(grid_data, detector_id): """ 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: raise ValueError('detector_id must be specified for WFPC2 ePSFs') if detector_id not in range(1, 5): raise ValueError('detector_id must be between 1 and 4, inclusive') nxdet = 2 nydet = 2 det_size = 800 # det (exten:idx) # WF2 (2:2) PC (1:3) # WF3 (3:0) WF4 (4:1) det_map = {1: 3, 2: 2, 3: 0, 4: 1} detector_data = {'nxdet': nxdet, 'nydet': nydet, 'det_size': det_size, 'det_map': det_map} return _split_detectors(grid_data, detector_data, detector_id) def _split_nrcsw(grid_data, detector_id): """ 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: raise ValueError('detector_id must be specified for NRCSW ePSFs') if detector_id not in range(1, 9): raise ValueError('detector_id must be between 1 and 8, inclusive') nxdet = 4 nydet = 2 det_size = 2048 # det (ext:idx) # A2 (2:4) A4 (4:5) B3 (7:6) B1 (5:7) # A1 (1:0) A3 (3:1) B4 (8:2) B2 (6:3) det_map = {1: 0, 3: 1, 8: 2, 6: 3, 2: 4, 4: 5, 7: 6, 5: 7} detector_data = {'nxdet': nxdet, 'nydet': nydet, 'det_size': det_size, 'det_map': det_map} return _split_detectors(grid_data, detector_data, detector_id) def _get_metadata(filename, detector_id): """ Get metadata from the filename and ``detector_id``. Parameters ---------- filename : str The name of the STDPDF FITS file. detector_id : int The detector ID. Returns ------- meta : dict or `None` A dictionary containing the metadata. """ if isinstance(filename, io.FileIO): filename = filename.name parts = os.path.basename(filename).strip('.fits').split('_') if len(parts) not in (3, 4): return None # filename from astropy download_file detector, filter_name = parts[1:3] meta = {'STDPSF': filename, 'detector': detector, 'filter': filter_name} if detector_id is not None: detector_map = {'WFPC2': ['HST/WFPC2', 'WFPC2'], 'ACSHRC': ['HST/ACS', 'HRC'], 'ACSWFC': ['HST/ACS', 'WFC'], 'WFC3UV': ['HST/WFC3', 'UVIS'], 'WFC3IR': ['HST/WFC3', 'IR'], 'NRCSW': ['JWST/NIRCam', 'NRCSW'], 'NRCA1': ['JWST/NIRCam', 'A1'], 'NRCA2': ['JWST/NIRCam', 'A2'], 'NRCA3': ['JWST/NIRCam', 'A3'], 'NRCA4': ['JWST/NIRCam', 'A4'], 'NRCB1': ['JWST/NIRCam', 'B1'], 'NRCB2': ['JWST/NIRCam', 'B2'], 'NRCB3': ['JWST/NIRCam', 'B3'], 'NRCB4': ['JWST/NIRCam', 'B4'], 'NRCAL': ['JWST/NIRCam', 'A5'], 'NRCBL': ['JWST/NIRCam', 'B5'], 'NIRISS': ['JWST/NIRISS', 'NIRISS'], 'MIRI': ['JWST/MIRI', 'MIRIM']} try: inst_det = detector_map[detector] except KeyError as exc: raise ValueError(f'Unknown detector {detector}.') from exc if inst_det[1] == 'WFPC2': wfpc2_map = {1: 'PC', 2: 'WF2', 3: 'WF3', 4: 'WF4'} inst_det[1] = wfpc2_map[detector_id] if inst_det[1] in ('WFC', 'UVIS'): chip = 2 if detector_id == 1 else 1 inst_det[1] = f'{inst_det[1]}{chip}' if inst_det[1] == 'NRCSW': sw_map = {1: 'A1', 2: 'A2', 3: 'A3', 4: 'A4', 5: 'B1', 6: 'B2', 7: 'B3', 8: 'B4'} inst_det[1] = sw_map[detector_id] meta['instrument'] = inst_det[0] meta['detector'] = inst_det[1] return meta def stdpsf_reader(filename, detector_id=None): """ Generate a `~photutils.psf.GriddedPSFModel` from a STScI standard- format ePSF (STDPSF) FITS file. .. note:: Instead of being used directly, this function is intended to be used via the `~photutils.psf.GriddedPSFModel` ``read`` method, e.g., ``model = GriddedPSFModel.read(filename, format='stdpsf')``. STDPSF files are FITS files that contain a 3D array of ePSFs with the header detailing where the fiducial ePSFs are located in the detector coordinate frame. The oversampling factor for STDPSF FITS files is assumed to be 4. Parameters ---------- filename : str The name of the STDPDF FITS file. A URL can also be used. detector_id : `None` or int, optional For STDPSF files that contain ePSF grids for multiple detectors, one will need to identify the detector for which to extract the ePSF grid. This keyword is ignored for STDPSF files that do not contain ePSF grids for multiple detectors. For WFPC2, the detector value (int) should be: * 1: PC, 2: WF2, 3: WF3, 4: WF4 For ACS/WFC and WFC3/UVIS, the detector value should be: * 1: WFC2, UVIS2 (sci, 1) * 2: WFC1, UVIS1 (sci, 2) Note that for these two instruments, detector 1 is above detector 2 in the y direction. However, in the FLT FITS files, the (sci, 1) extension corresponds to detector 2 (WFC2, UVIS2) and the (sci, 2) extension corresponds to detector 1 (WFC1, UVIS1). For NIRCam NRCSW files that contain ePSF grids for all 8 SW detectors, the detector value should be: * 1: A1, 2: A2, 3: A3, 4: A4 * 5: B1, 6: B2, 7: B3, 8: B4 Returns ------- model : `~photutils.psf.GriddedPSFModel` The gridded ePSF model. """ from photutils.psf import GriddedPSFModel # prevent circular import grid_data = _read_stdpsf(filename) npsfs = grid_data['npsfs'] if npsfs in (90, 56, 36, 200): if npsfs in (90, 56): # ACS/WFC or WFC3/UVIS data (2 chips) data, xgrid, ygrid = _split_wfc_uvis(grid_data, detector_id) elif npsfs == 36: # WFPC2 data (4 chips) data, xgrid, ygrid = _split_wfpc2(grid_data, detector_id) elif npsfs == 200: # NIRCam SW data (8 chips) data, xgrid, ygrid = _split_nrcsw(grid_data, detector_id) else: raise ValueError('Unknown detector or STDPSF format') else: data = grid_data['data'] xgrid = grid_data['xgrid'] ygrid = grid_data['ygrid'] # itertools.product iterates over the last input first xy_grid = [yx[::-1] for yx in itertools.product(ygrid, xgrid)] oversampling = 4 # assumption for STDPSF files nxpsfs = xgrid.shape[0] nypsfs = ygrid.shape[0] meta = {'grid_xypos': xy_grid, 'oversampling': oversampling, 'nxpsfs': nxpsfs, 'nypsfs': nypsfs} # try to get additional metadata from the filename because this # information is not currently available in the FITS headers file_meta = _get_metadata(filename, detector_id) if file_meta is not None: meta.update(file_meta) return GriddedPSFModel(NDData(data, meta=meta)) def webbpsf_reader(filename): """ Generate a `~photutils.psf.GriddedPSFModel` from a WebbPSF FITS file containing a PSF grid. .. note:: Instead of being used directly, this function is intended to be used via the `~photutils.psf.GriddedPSFModel` ``read`` method, e.g., ``model = GriddedPSFModel.read(filename, format='webbpsf')``. The WebbPSF FITS file contain a 3D array of ePSFs with the header detailing where the fiducial ePSFs are located in the detector coordinate frame. Parameters ---------- filename : str The name of the WebbPSF FITS file. A URL can also be used. Returns ------- model : `~photutils.psf.GriddedPSFModel` The gridded ePSF model. """ from photutils.psf import GriddedPSFModel # prevent circular import with warnings.catch_warnings(): warnings.simplefilter('ignore', VerifyWarning) with fits.open(filename, ignore_missing_end=True) as hdulist: header = hdulist[0].header data = hdulist[0].data # handle the case of only one 2D PSF data = np.atleast_3d(data) if not any('DET_YX' in key for key in header): raise ValueError('Invalid WebbPSF FITS file; missing "DET_YX{}" ' 'header keys.') if 'OVERSAMP' not in header: raise ValueError('Invalid WebbPSF FITS file; missing "OVERSAMP" ' 'header key.') # convert header to meta dict header = header.copy(strip=True) header.pop('HISTORY', None) header.pop('COMMENT', None) header.pop('', None) meta = dict(header) meta = {key.lower(): meta[key] for key in meta} # user lower-case keys # define grid_xypos from DET_YX{} FITS header keywords xypos = [] for key in meta: if 'det_yx' in key: vals = header[key].lstrip('(').rstrip(')').split(',') xypos.append((float(vals[0]), float(vals[1]))) meta['grid_xypos'] = xypos if 'oversampling' not in meta: meta['oversampling'] = meta['oversamp'] ndd = NDData(data, meta=meta) return GriddedPSFModel(ndd) def is_stdpsf(origin, filepath, fileobj, *args, **kwargs): """ Determine whether a file is a STDPSF FITS file. Parameters ---------- origin : {'read', 'write'} A string indicating whether the file is to be opened for reading or writing. filepath : str The file path of the FITS file. fileobj : file-like object An open file object to read the file's contents, or `None` if the file could not be opened. *args, **kwargs Any additional positional or keyword arguments for the read or write function. Returns ------- result : bool Returns `True` if the given file is a STDPSF FITS file. """ if filepath is not None: extens = ('.fits', '.fits.gz', '.fit', '.fit.gz', '.fts', '.fts.gz') isfits = filepath.lower().endswith(extens) if isfits: with warnings.catch_warnings(): warnings.simplefilter('ignore', VerifyWarning) header = fits.getheader(filepath) keys = ('NAXIS3', 'NXPSFS', 'NYPSFS') return all(key in header for key in keys) return False def is_webbpsf(origin, filepath, fileobj, *args, **kwargs): """ Determine whether a file is a WebbPSF FITS file. Parameters ---------- origin : {'read', 'write'} A string indicating whether the file is to be opened for reading or writing. filepath : str The file path of the FITS file. fileobj : file-like object An open file object to read the file's contents, or `None` if the file could not be opened. *args, **kwargs Any additional positional or keyword arguments for the read or write function. Returns ------- result : bool Returns `True` if the given file is a WebbPSF FITS file. """ if filepath is not None: extens = ('.fits', '.fits.gz', '.fit', '.fit.gz', '.fts', '.fts.gz') isfits = filepath.lower().endswith(extens) if isfits: with warnings.catch_warnings(): warnings.simplefilter('ignore', VerifyWarning) header = fits.getheader(filepath) keys = ('NAXIS3', 'OVERSAMP', 'DET_YX0') return all(key in header for key in keys) return False ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/model_plotting.py0000644000175100001660000002171414755160622021403 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines tools to plot Gridded PSF models. """ import astropy import numpy as np from astropy.utils import minversion from astropy.visualization import simple_norm __all__ = ['ModelGridPlotMixin'] class ModelGridPlotMixin: """ Mixin class to plot a grid of ePSF models. """ def _reshape_grid(self, data): """ Reshape the 3D ePSF grid as a 2D array of horizontally and vertically stacked ePSFs. Parameters ---------- data : `numpy.ndarray` The 3D array of ePSF data. Returns ------- reshaped_data : `numpy.ndarray` The 2D array of ePSF data. """ nypsfs = self._ygrid.shape[0] nxpsfs = self._xgrid.shape[0] ny, nx = self.data.shape[1:] data.shape = (nypsfs, nxpsfs, ny, nx) return data.transpose([0, 2, 1, 3]).reshape(nypsfs * ny, nxpsfs * nx) def plot_grid(self, *, ax=None, vmax_scale=None, peak_norm=False, deltas=False, cmap='viridis', dividers=True, divider_color='darkgray', divider_ls='-', figsize=None): """ Plot the grid of ePSF models. Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. vmax_scale : float, optional Scale factor to apply to the image stretch limits. This value is multiplied by the peak ePSF value to determine the plotting ``vmax``. The defaults are 1.0 for plotting the ePSF data and 0.03 for plotting the ePSF difference data (``deltas=True``). If ``deltas=True``, the ``vmin`` is set to ``-vmax``. If ``deltas=False`` the ``vmin`` is set to ``vmax`` / 1e4. peak_norm : bool, optional Whether to normalize the ePSF data by the peak value. The default shows the ePSF flux per pixel. deltas : bool, optional Set to `True` to show the differences between each ePSF and the average ePSF. cmap : str or `matplotlib.colors.Colormap`, optional The colormap to use. The default is 'viridis'. dividers : bool, optional Whether to show divider lines between the ePSFs. divider_color, divider_ls : str, optional Matplotlib color and linestyle options for the divider lines between ePSFs. These keywords have no effect unless ``show_dividers=True``. figsize : (float, float), optional The figure (width, height) in inches. Returns ------- fig : `matplotlib.figure.Figure` The matplotlib figure object. This will be the current figure if ``ax=None``. Use ``fig.savefig()`` to save the figure to a file. Notes ----- This method returns a figure object. If you are using this method in a script, you will need to call ``plt.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 mpl_toolkits.axes_grid1 import make_axes_locatable data = self.data.copy() if deltas: # Compute mean ignoring any blank (all zeros) ePSFs. # This is the case for MIRI with its non-square FOV. mask = np.zeros(data.shape[0], dtype=bool) for i, arr in enumerate(data): if np.count_nonzero(arr) == 0: mask[i] = True data -= np.mean(data[~mask], axis=0) data[mask] = 0.0 data = self._reshape_grid(data) if ax is None: if figsize is None and self.meta.get('detector', '') == 'NRCSW': figsize = (20, 8) fig, ax = plt.subplots(figsize=figsize) else: fig = plt.gcf() if peak_norm and data.max() != 0: # normalize relative to peak data /= data.max() if deltas: if vmax_scale is None: vmax_scale = 0.03 vmax = data.max() * vmax_scale vmin = -vmax if minversion(astropy, '6.1'): norm = simple_norm(data, 'linear', vmin=vmin, vmax=vmax) else: norm = simple_norm(data, 'linear', min_cut=vmin, max_cut=vmax) else: if vmax_scale is None: vmax_scale = 1.0 vmax = data.max() * vmax_scale vmin = vmax / 1.0e4 if minversion(astropy, '6.1'): norm = simple_norm(data, 'log', vmin=vmin, vmax=vmax, log_a=1.0e4) else: norm = simple_norm(data, 'log', min_cut=vmin, max_cut=vmax, log_a=1.0e4) # Set up the coordinate axes to later set tick labels based on # detector ePSF coordinates. This sets up axes to have, behind the # scenes, the ePSFs centered at integer coords 0, 1, 2, 3 etc. # extent order: left, right, bottom, top nypsfs = self._ygrid.shape[0] nxpsfs = self._xgrid.shape[0] extent = [-0.5, nxpsfs - 0.5, -0.5, nypsfs - 0.5] 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._xgrid.astype(int) yticklabels = self._ygrid.astype(int) if self.meta.get('detector', '') == 'NRCSW': xticklabels = list(xticklabels[0:5]) * 4 yticklabels = list(yticklabels[0:5]) * 2 ax.set_xticks(np.arange(nxpsfs)) ax.set_xticklabels(xticklabels) ax.set_xlabel('ePSF location in detector X pixels') ax.set_yticks(np.arange(nypsfs)) ax.set_yticklabels(yticklabels) ax.set_ylabel('ePSF location in detector Y pixels') if dividers: for ix in range(nxpsfs - 1): ax.axvline(ix + 0.5, color=divider_color, ls=divider_ls) for iy in range(nypsfs - 1): ax.axhline(iy + 0.5, color=divider_color, ls=divider_ls) instrument = self.meta.get('instrument', '') if not instrument: # WebbPSF output instrument = self.meta.get('instrume', '') detector = self.meta.get('detector', '') filtername = self.meta.get('filter', '') # WebbPSF outputs a tuple with the comment in the second element if isinstance(instrument, (tuple, list, np.ndarray)): instrument = instrument[0] if isinstance(detector, (tuple, list, np.ndarray)): detector = detector[0] if isinstance(filtername, (tuple, list, np.ndarray)): filtername = filtername[0] title = f'{instrument} {detector} {filtername}' if title != '': # add extra space at end title += ' ' if deltas: 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.meta.get('detector', '') == 'NRCSW': # NIRCam NRCSW STDPSF files contain all detectors. # The plot gets extra divider lines and SCA name labels. nxpsfs = len(self._xgrid) nypsfs = len(self._ygrid) plt.axhline(nypsfs / 2 - 0.5, color='orange') for i in range(1, 4): ax.axvline(nxpsfs / 4 * i - 0.5, color='orange') det_labels = [['A1', 'A3', 'B4', 'B2'], ['A2', 'A4', 'B3', 'B1']] for i in range(2): for j in range(4): ax.text(j * nxpsfs / 4 - 0.45, (i + 1) * nypsfs / 2 - 0.55, det_labels[i][j], color='orange', verticalalignment='top', fontsize=12) fig.tight_layout() return fig ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/photometry.py0000644000175100001660000025116414755160622020601 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides classes to perform PSF-fitting photometry. """ import contextlib import inspect import warnings from collections import defaultdict from copy import deepcopy from itertools import chain import astropy.units as u import numpy as np from astropy.modeling.fitting import TRFLSQFitter from astropy.nddata import NDData, NoOverlapError, StdDevUncertainty from astropy.table import QTable, Table, hstack, join, vstack from astropy.utils import lazyproperty from astropy.utils.decorators import deprecated_attribute from astropy.utils.exceptions import AstropyUserWarning from photutils.aperture import CircularAperture from photutils.background import LocalBackground from photutils.datasets import make_model_image as _make_model_image from photutils.psf.groupers import SourceGrouper from photutils.psf.utils import _get_psf_model_params, _validate_psf_model from photutils.utils._misc import _get_meta from photutils.utils._parameters import as_pair from photutils.utils._progress_bars import add_progress_bar from photutils.utils._quantity_helpers import process_quantities from photutils.utils.cutouts import _overlap_slices as overlap_slices from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['IterativePSFPhotometry', 'ModelImageMixin', 'PSFPhotometry'] class ModelImageMixin: """ Mixin class to provide methods to calculate model images and residuals. """ def make_model_image(self, shape, psf_shape=None, include_localbkg=False): """ Create a 2D image from the fit PSF models and optional local background. Parameters ---------- shape : 2 tuple of int The shape of the output array. psf_shape : 2 tuple of int, 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_localbkg : bool, optional Whether to include the local background in the rendered output image. Note that the local background level is included around each source over the region defined by ``psf_shape``. Thus, regions where the ``psf_shape`` of sources overlap will have the local background added multiple times. Returns ------- array : 2D `~numpy.ndarray` The rendered image from the fit PSF models. This image will not have any units. """ if isinstance(self, PSFPhotometry): progress_bar = self.progress_bar psf_model = self.psf_model fit_params = self._fit_model_params local_bkgs = self.init_params['local_bkg'] else: psf_model = self._psfphot.psf_model progress_bar = self._psfphot.progress_bar if self.mode == 'new': # collect the fit params and local backgrounds from each # iteration local_bkgs = [] for i, psfphot in enumerate(self.fit_results): if i == 0: fit_params = psfphot._fit_model_params else: fit_params = vstack((fit_params, psfphot._fit_model_params)) local_bkgs.append(psfphot.init_params['local_bkg']) local_bkgs = _flatten(local_bkgs) else: # use the fit params and local backgrounds only from the # final iteration, which includes all sources fit_params = self.fit_results[-1]._fit_model_params local_bkgs = self.fit_results[-1].init_params['local_bkg'] model_params = fit_params if include_localbkg: # add local_bkg model_params = model_params.copy() model_params['local_bkg'] = local_bkgs try: x_name = psf_model.x_name y_name = psf_model.y_name except AttributeError: x_name = 'x_0' y_name = 'y_0' return _make_model_image(shape, psf_model, model_params, model_shape=psf_shape, x_name=x_name, y_name=y_name, progress_bar=progress_bar) def make_residual_image(self, data, psf_shape=None, include_localbkg=False): """ Create a 2D residual image from the fit PSF models and local background. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array on which photometry was performed. This should be the same array input when calling the PSF-photometry class. psf_shape : 2 tuple of int, 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_localbkg : bool, optional Whether to include the local background in the subtracted model. Note that the local background level is subtracted around each source over the region defined by ``psf_shape``. Thus, regions where the ``psf_shape`` of sources overlap will have the local background subtracted multiple times. Returns ------- array : 2D `~numpy.ndarray` The residual image of the ``data`` minus the fit PSF models minus the optional``local_bkg``. """ if isinstance(data, NDData): residual = deepcopy(data) data_arr = data.data if data.unit is not None: data_arr <<= data.unit residual.data[:] = self.make_residual_image( data_arr, psf_shape=psf_shape, include_localbkg=include_localbkg) else: residual = self.make_model_image(data.shape, psf_shape=psf_shape, include_localbkg=include_localbkg) np.subtract(data, residual, out=residual) return residual class PSFPhotometry(ModelImageMixin): """ Class to perform PSF photometry. This class implements a flexible PSF photometry algorithm that can find sources in an image, group overlapping sources, fit the PSF model to the sources, and subtract the fit PSF models from the image. Parameters ---------- psf_model : 2D `astropy.modeling.Model` The PSF model to fit to the data. The model must have parameters named ``x_0``, ``y_0``, and ``flux``, corresponding to the center (x, y) position and flux, or it must have 'x_name', 'y_name', and 'flux_name' attributes that map to the x, y, and flux parameters (i.e., a model output from `make_psf_model`). The model must be two-dimensional such that it accepts 2 inputs (e.g., x and y) and provides 1 output. fit_shape : int or length-2 array_like The rectangular shape around the center of a star that will be used to define the PSF-fitting data. If ``fit_shape`` is a scalar then a square shape of size ``fit_shape`` will be used. If ``fit_shape`` has two elements, they must be in ``(ny, nx)`` order. Each element of ``fit_shape`` must be an odd number. In general, ``fit_shape`` should be set to a small size (e.g., ``(5, 5)``) that covers the region with the highest flux signal-to-noise. finder : callable or `~photutils.detection.StarFinderBase` or `None`, \ optional A callable used to identify stars in an image. The ``finder`` must accept a 2D image as input and return a `~astropy.table.Table` containing the x and y centroid positions. These positions are used as the starting points for the PSF fitting. The allowed ``x`` column names are (same suffix for ``y``): ``'x_init'``, ``'xinit'``, ``'x'``, ``'x_0'``, ``'x0'``, ``'xcentroid'``, ``'x_centroid'``, ``'x_peak'``, ``'xcen'``, ``'x_cen'``, ``'xpos'``, ``'x_pos'``, ``'x_fit'``, and ``'xfit'``. If `None`, then the initial (x, y) model positions must be input using the ``init_params`` keyword when calling the class. The (x, y) values in ``init_params`` override this keyword. If this class is run on an image that has units (i.e., a `~astropy.units.Quantity` array), then certain ``finder`` keywords (e.g., ``threshold``) must have the same units. Please see the documentation for the specific ``finder`` class for more information. grouper : `~photutils.psf.SourceGrouper` or callable or `None`, optional A callable used to group stars. Typically, grouped stars are those that overlap with their neighbors. Stars that are grouped are fit simultaneously. The ``grouper`` must accept the x and y coordinates of the sources and return an integer array of the group id numbers (starting from 1) indicating the group in which a given source belongs. If `None`, then no grouping is performed, i.e. each source is fit independently. The ``group_id`` values in ``init_params`` override this keyword. A warning is raised if any group size is larger than 25 sources. fitter : `~astropy.modeling.fitting.Fitter`, optional The fitter object used to perform the fit of the model to the data. fitter_maxiters : int, optional The maximum number of iterations in which the ``fitter`` is called for each source. This keyword can be increased if the fit is not converging for sources (e.g., the output ``flags`` value contains 8). 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 in that direction. localbkg_estimator : `~photutils.background.LocalBackground` or `None`, \ optional The object used to estimate the local background around each source. If `None`, then no local background is subtracted. The ``local_bkg`` values in ``init_params`` override this keyword. This option should be used with care, especially in crowded fields where the ``fit_shape`` of sources overlap (see Notes below). aperture_radius : float, optional The radius of the circular aperture used to estimate the initial flux of each source. If initial flux values are present in the ``init_params`` table, they will override this keyword. progress_bar : bool, optional Whether to display a progress bar when fitting the sources (or groups). The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. Notes ----- The data that will be fit for each source is defined by the ``fit_shape`` parameter. A cutout will be made around the initial center of each source with a shape defined by ``fit_shape``. The PSF model will be fit to the data in this region. The cutout region that is fit does not shift if the source center shifts during the fit iterations. Therefore, the initial source positions should be close to the true source positions. One way to ensure this is to use a ``finder`` to identify sources in the data. If the fitted positions are significantly different from the initial positions, one can rerun the `PSFPhotometry` class using the fit results as the input ``init_params``, which will change the fitted cutout region for each source. After calling `PSFPhotometry` on the data, it will have a ``fit_params`` attribute containing the fitted model parameters. This table can be used as the ``init_params`` input in a subsequent call to `PSFPhotometry`. If the returned model parameter errors are NaN, then either the fit did not converge, the model parameter was fixed, or the input ``fitter`` did not return parameter errors. For the later case, one can try a different fitter that may return parameter errors (e.g., `astropy.modeling.fitting.DogBoxLSQFitter` or `astropy.modeling.fitting.LMLSQFitter`). The local background value around each source is optionally estimated using the ``localbkg_estimator`` or obtained from the ``local_bkg`` column in the input ``init_params`` table. This local background is then subtracted from the data over the ``fit_shape`` region for each source before fitting the PSF model. For sources where their ``fit_shape`` regions overlap, the local background will effectively be subtracted twice in the overlapping ``fit_shape`` regions, even if the source ``grouper`` is input. This is not an issue if the sources are well-separated. However, for crowded fields, please use the ``localbkg_estimator`` (or ``local_bkg`` column in ``init_params``) with care. Care should be taken in defining the star groups. Simultaneously fitting very large star groups is computationally expensive and error-prone. Internally, source grouping requires the creation of a compound Astropy model. Due to the way compound Astropy models are currently constructed, large groups also require excessively large amounts of memory; this will hopefully be fixed in a future Astropy version. A warning will be raised if the number of sources in a group exceeds 25. """ fit_results = deprecated_attribute('fit_results', '2.0.0', alternative='fit_info') def __init__(self, psf_model, fit_shape, *, finder=None, grouper=None, fitter=TRFLSQFitter(), fitter_maxiters=100, xy_bounds=None, localbkg_estimator=None, aperture_radius=None, progress_bar=False): self._param_maps = self._define_param_maps(psf_model) self.psf_model = _validate_psf_model(psf_model) self.fit_shape = as_pair('fit_shape', fit_shape, lower_bound=(1, 0), check_odd=True) self.grouper = self._validate_grouper(grouper) self.finder = self._validate_callable(finder, 'finder') self.fitter = self._validate_callable(fitter, 'fitter') self.localbkg_estimator = self._validate_localbkg( localbkg_estimator, 'localbkg_estimator') self.fitter_maxiters = self._validate_maxiters(fitter_maxiters) self.xy_bounds = self._validate_bounds(xy_bounds) self.aperture_radius = self._validate_radius(aperture_radius) self.progress_bar = progress_bar # be sure to reset these attributes for each __call__ # (see _reset_results) self.data_unit = None self.finder_results = None self.init_params = None self.fit_params = None self._fit_model_params = None self.results = None self.fit_info = defaultdict(list) self._group_results = defaultdict(list) def _reset_results(self): """ Reset these attributes for each __call__. """ self.data_unit = None self.finder_results = None self.init_params = None self.fit_params = None self._fit_model_params = None self.results = None self.fit_info = defaultdict(list) self._group_results = defaultdict(list) def _validate_grouper(self, grouper): if grouper is not None and not isinstance(grouper, SourceGrouper): raise ValueError('grouper must be a SourceGrouper instance.') return grouper @staticmethod def _define_model_params_map(psf_model): # The main parameter names are checked together as a unit in the # following order: # * ('x_0', 'y_0', 'flux') parameters # * ('x_name', 'y_name', 'flux_name') attributes main_params = _get_psf_model_params(psf_model) main_aliases = ('x', 'y', 'flux') params_map = dict(zip(main_aliases, main_params, strict=True)) # define the fitted model parameters fitted_params = [] for key, val in psf_model.fixed.items(): if not val: fitted_params.append(key) # define the "extra" fitted model parameters that do not # correspond to x, y, or flux extra_params = [key for key in fitted_params if key not in main_params] other_params = {key: key for key in extra_params} params_map.update(other_params) return params_map def _define_param_maps(self, psf_model): """ Map x, y, and flux column names to the PSF model parameter names. Also include any extra PSF model parameters that are fit, but do not correspond to x, y, or flux. The column names include the ``_init``, ``_fit``, and ``_err`` suffixes for each parameter. """ params_map = self._define_model_params_map(psf_model) param_maps = {} param_maps['model'] = params_map # Keep track of only the fitted parameters in the same order as # they are stored in the psf_model. This is used to extract the # fitted parameter errors from the fitter output. fit_params = {} inv_pmap = {val: key for key, val in params_map.items()} for name in psf_model.param_names: if not psf_model.fixed[name]: fit_params[inv_pmap[name]] = name param_maps['fit_params'] = fit_params suffixes = ('init', 'fit', 'err') for suffix in suffixes: pmap = {} for key, val in params_map.items(): pmap[val] = f'{key}_{suffix}' param_maps[suffix] = pmap init_cols = {} for key in param_maps['model']: init_cols[key] = f'{key}_init' param_maps['init_cols'] = init_cols return param_maps @staticmethod def _validate_callable(obj, name): if obj is not None and not callable(obj): raise TypeError(f'{name!r} must be a callable object') return obj def _validate_localbkg(self, value, name): if value is not None and not isinstance(value, LocalBackground): raise ValueError('localbkg_estimator must be a ' 'LocalBackground instance.') return self._validate_callable(value, name) def _validate_maxiters(self, maxiters): spec = inspect.signature(self.fitter.__call__) if 'maxiter' not in spec.parameters: warnings.warn('"maxiters" will be ignored because it is not ' 'accepted by the input fitter __call__ method', AstropyUserWarning) maxiters = None return maxiters def _validate_bounds(self, xy_bounds): 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: raise ValueError('xy_bounds must have 1 or 2 elements') if xy_bounds.ndim != 1: raise ValueError('xy_bounds must be a 1D array') non_none = [i for i in xy_bounds if i is not None] if np.any(np.array(non_none) <= 0): raise ValueError('xy_bounds must be strictly positive') return xy_bounds @staticmethod def _validate_radius(radius): if radius is not None and (not np.isscalar(radius) or radius <= 0 or ~np.isfinite(radius)): raise ValueError('aperture_radius must be a strictly-positive ' 'scalar') return radius def _validate_array(self, array, name, data_shape=None): if name == 'mask' and array is np.ma.nomask: array = None if array is not None: array = np.asanyarray(array) if array.ndim != 2: raise ValueError(f'{name} must be a 2D array.') if data_shape is not None and array.shape != data_shape: raise ValueError(f'data and {name} must have the same shape.') return array @lazyproperty def _valid_colnames(self): """ A dictionary of valid column names for the input ``init_params`` table. These lists are searched in order. """ xy_suffixes = ('_init', 'init', '', '_0', '0', 'centroid', '_centroid', '_peak', 'cen', '_cen', 'pos', '_pos', '_fit', 'fit') x_valid = ['x' + i for i in xy_suffixes] y_valid = ['y' + i for i in xy_suffixes] valid_colnames = {} valid_colnames['x'] = x_valid valid_colnames['y'] = y_valid valid_colnames['flux'] = ('flux_init', 'fluxinit', 'flux', 'flux_0', 'flux0', 'flux_fit', 'fluxfit', 'source_sum', 'segment_flux', 'kron_flux') return valid_colnames def _find_column_name(self, key, colnames): """ Find the first valid matching column name for x, y, or flux (defined by `_valid_colnames` in the input ``init_params`` table). """ name = '' try: valid_names = self._valid_colnames[key] except KeyError: # parameters other than (x, y, flux) must have "_init", "", # or "_fit" suffixes valid_names = [f'{key}_init', key, f'{key}_fit'] for valid_name in valid_names: if valid_name in colnames: name = valid_name break # return the first match return name def _check_init_units(self, init_params, colname): values = init_params[colname] if isinstance(values, u.Quantity): if self.data_unit is None: raise ValueError(f'init_params {colname} column has ' 'units, but the input data does not ' 'have units.') try: init_params[colname] = values.to(self.data_unit) except u.UnitConversionError as exc: raise ValueError(f'init_params {colname} column has ' 'units that are incompatible with ' 'the input data units.') from exc elif self.data_unit is not None: raise ValueError('The input data has units, but the ' f'init_params {colname} column does not ' 'have units.') return init_params @staticmethod def _rename_init_columns(init_params, param_maps, find_column_name): """ Rename the columns in the input ``init_params`` table to the expected names with the "_init" suffix if necessary. This is a static method to allow the method to be called from `IterativePSFPhotometry`. """ for param in param_maps['model']: colname = find_column_name(param, init_params.colnames) if colname: init_name = param_maps['init_cols'][param] if colname != init_name: init_params.rename_column(colname, init_name) return init_params def _validate_init_params(self, init_params): """ Validate the input ``init_params`` table. Also rename the columns to the expected names with the "_init" suffix if necessary. """ if init_params is None: return init_params if not isinstance(init_params, Table): raise TypeError('init_params must be an astropy Table') # copy is used to preserve the input init_params init_params = self._rename_init_columns(init_params.copy(), self._param_maps, self._find_column_name) # x and y columns are always required xcolname = self._param_maps['init_cols']['x'] ycolname = self._param_maps['init_cols']['y'] if (xcolname not in init_params.colnames or ycolname not in init_params.colnames): raise ValueError('init_param must contain valid column names ' 'for the x and y source positions') fluxcolname = self._param_maps['init_cols']['flux'] if fluxcolname in init_params.colnames: init_params = self._check_init_units(init_params, fluxcolname) if 'local_bkg' in init_params.colnames: if not np.all(np.isfinite(init_params['local_bkg'])): raise ValueError('init_params local_bkg column contains ' 'non-finite values.') init_params = self._check_init_units(init_params, 'local_bkg') return init_params @staticmethod def _make_mask(image, mask): def warn_nonfinite(): warnings.warn('Input data contains unmasked non-finite values ' '(NaN or inf), which were automatically ignored.', AstropyUserWarning) # if NaNs are in the data, no actual fitting takes place # https://github.com/astropy/astropy/pull/12811 finite_mask = ~np.isfinite(image) if mask is not None: finite_mask |= mask if np.any(finite_mask & ~mask): warn_nonfinite() else: mask = finite_mask if np.any(finite_mask): warn_nonfinite() else: mask = None return mask def _get_aper_fluxes(self, data, mask, init_params): xpos = init_params[self._param_maps['init_cols']['x']] ypos = init_params[self._param_maps['init_cols']['y']] apertures = CircularAperture(zip(xpos, ypos, strict=True), r=self.aperture_radius) flux, _ = apertures.do_photometry(data, mask=mask) return flux def _prepare_init_params(self, data, mask, init_params): xcolname = self._param_maps['init_cols']['x'] ycolname = self._param_maps['init_cols']['y'] fluxcolname = self._param_maps['init_cols']['flux'] if init_params is None: if self.finder is None: raise ValueError('finder must be defined if init_params ' 'is not input') # restore units to the input data (stripped earlier) if self.data_unit is not None: sources = self.finder(data << self.data_unit, mask=mask) else: sources = self.finder(data, mask=mask) if sources is None: return None self.finder_results = sources init_params = QTable() init_params['id'] = np.arange(len(sources)) + 1 init_params[xcolname] = sources['xcentroid'] init_params[ycolname] = sources['ycentroid'] else: colnames = init_params.colnames if 'id' not in colnames: init_params['id'] = np.arange(len(init_params)) + 1 if 'local_bkg' not in init_params.colnames: if self.localbkg_estimator is None: local_bkg = np.zeros(len(init_params)) else: local_bkg = self.localbkg_estimator( data, init_params[xcolname], init_params[ycolname], mask=mask) if self.data_unit is not None: local_bkg <<= self.data_unit init_params['local_bkg'] = local_bkg if fluxcolname not in init_params.colnames: flux = self._get_aper_fluxes(data, mask, init_params) if self.data_unit is not None: flux <<= self.data_unit flux -= init_params['local_bkg'] init_params[fluxcolname] = flux if 'group_id' in init_params.colnames: # grouper is ignored if group_id is input in init_params self.grouper = None if self.grouper is not None: group_id = self.grouper(init_params[xcolname], init_params[ycolname]) else: group_id = init_params['id'].copy() init_params['group_id'] = group_id # add columns for any additional parameters that are fit for param_name, colname in self._param_maps['init'].items(): if colname not in init_params.colnames: init_params[colname] = getattr(self.psf_model, param_name) extra_param_cols = [] for colname in self._param_maps['init_cols'].values(): if colname in (xcolname, ycolname, fluxcolname): continue extra_param_cols.append(colname) # order init_params columns colname_order = ['id', 'group_id', 'local_bkg', xcolname, ycolname, fluxcolname] colname_order.extend(extra_param_cols) return init_params[colname_order] def _get_invalid_positions(self, init_params, shape): """ Get a mask of sources with no overlap with the data. This code is based on astropy.nddata.overlap_slices. """ x = init_params[self._param_maps['init_cols']['x']] y = init_params[self._param_maps['init_cols']['y']] positions = np.column_stack((y, x)) delta = self.fit_shape / 2 min_idx = np.ceil(positions - delta) max_idx = np.ceil(positions + delta) return np.any(max_idx <= 0, axis=1) | np.any(min_idx >= shape, axis=1) def _check_init_positions(self, init_params, shape): """ Check the initial source positions to ensure they are within the data shape. """ if np.any(self._get_invalid_positions(init_params, shape)): raise ValueError('Some of the sources have no overlap with the ' 'data. Check the initial source positions or ' 'increase the fit_shape.') def _make_psf_model(self, sources): """ Make a PSF model to fit a single source or several sources within a group. """ init_param_map = self._param_maps['init'] for index, source in enumerate(sources): model = self.psf_model.copy() for model_param, init_col in init_param_map.items(): value = source[init_col] if isinstance(value, u.Quantity): value = value.value # psf model cannot be fit with units setattr(model, model_param, value) model.name = source['id'] if self.xy_bounds is not None: if self.xy_bounds[0] is not None: x_param = getattr(model, self._param_maps['model']['x']) x_param.bounds = (x_param.value - self.xy_bounds[0], x_param.value + self.xy_bounds[0]) if self.xy_bounds[1] is not None: y_param = getattr(model, self._param_maps['model']['y']) y_param.bounds = (y_param.value - self.xy_bounds[1], y_param.value + self.xy_bounds[1]) if index == 0: psf_model = model else: psf_model += model return psf_model @staticmethod def _move_column(table, colname, colname_after): """ Move a column to a new position in a table. The table is modified in place. Parameters ---------- table : `~astropy.table.Table` The input table. colname : str The column name to move. colname_after : str The column name after which to place the moved column. Returns ------- table : `~astropy.table.Table` The input table with the column moved. """ colnames = table.colnames if colname not in colnames or colname_after not in colnames: return table if colname == colname_after: return table old_index = colnames.index(colname) new_index = colnames.index(colname_after) if old_index > new_index: new_index += 1 colnames.insert(new_index, colnames.pop(old_index)) return table[colnames] def _model_params_to_table(self, models): """ Convert a list of PSF models to a table of model parameters. The inputs ``models`` are assumed to be instances of the same model class (i.e., the parameters names are the same for all models). """ param_names = list(models[0].param_names) params = defaultdict(list) for model in models: for name in param_names: param = getattr(model, name) value = param.value if (self.data_unit is not None and name == self._param_maps['model']['flux']): value <<= self.data_unit # add the flux units params[name].append(value) params[f'{name}_fixed'].append(param.fixed) params[f'{name}_bounds'].append(param.bounds) table = QTable(params) ids = np.arange(len(table)) + 1 table.add_column(ids, index=0, name='id') return table def _param_errors_to_table(self): param_err = self.fit_info.pop('fit_param_errs') err_param_map = self._param_maps['err'] table = QTable() for index, name in enumerate(self._param_maps['fit_params'].values()): colname = err_param_map[name] value = param_err[:, index] if (self.data_unit is not None and name == self._param_maps['model']['flux']): value <<= self.data_unit # add the flux units table[colname] = value colnames = list(err_param_map.values()) # add error columns for fixed params; errors are set to NaN nsources = len(self.init_params) for colname in colnames: if colname not in table.colnames: table[colname] = [np.nan] * nsources # sort column names return table[colnames] def _prepare_fit_results(self, fit_params): """ Prepare the output table of fit results. """ # remove parameters that are not fit out_params = fit_params.copy() for column in out_params.colnames: if column == 'id': continue if column not in self._param_maps['fit']: out_params.remove_column(column) # rename columns to have the "fit" suffix for key, val in self._param_maps['fit'].items(): out_params.rename_column(key, val) # reorder columns to have "flux" come immediately after "y" ymodelparam = self._param_maps['model']['y'] fluxmodelparam = self._param_maps['model']['flux'] y_col = self._param_maps['fit'][ymodelparam] flux_col = self._param_maps['fit'][fluxmodelparam] out_params = self._move_column(out_params, flux_col, y_col) # add parameter error columns param_errs = self._param_errors_to_table() return hstack([out_params, param_errs]) def _define_fit_data(self, sources, data, mask): yi = [] xi = [] cutout = [] npixfit = [] cen_index = [] for row in sources: xcen = row[self._param_maps['init_cols']['x']] ycen = row[self._param_maps['init_cols']['y']] try: slc_lg, _ = overlap_slices(data.shape, self.fit_shape, (ycen, xcen), mode='trim') except NoOverlapError as exc: # pragma: no cover # this should never happen because the initial positions # are checked in _prepare_fit_inputs msg = (f'Initial source at ({xcen}, {ycen}) does not ' 'overlap with the input data.') raise ValueError(msg) from exc yy, xx = np.mgrid[slc_lg] if mask is not None: inv_mask = ~mask[yy, xx] if np.count_nonzero(inv_mask) == 0: msg = (f'Source at ({xcen}, {ycen}) is completely masked. ' 'Remove the source from init_params or correct ' 'the input mask.') raise ValueError(msg) yy = yy[inv_mask] xx = xx[inv_mask] else: xx = xx.ravel() yy = yy.ravel() xi.append(xx) yi.append(yy) local_bkg = row['local_bkg'] if isinstance(local_bkg, u.Quantity): local_bkg = local_bkg.value cutout.append(data[yy, xx] - local_bkg) npixfit.append(len(xx)) # this is overlap_slices center pixel index (before any trimming) xcen = np.ceil(xcen - 0.5).astype(int) ycen = np.ceil(ycen - 0.5).astype(int) idx = np.where((xx == xcen) & (yy == ycen))[0] if len(idx) == 0: idx = [np.nan] cen_index.append(idx[0]) # flatten the lists, which may contain arrays of different lengths # due to masking xi = _flatten(xi) yi = _flatten(yi) cutout = _flatten(cutout) self._group_results['npixfit'].append(npixfit) self._group_results['psfcenter_indices'].append(cen_index) return yi, xi, cutout @staticmethod def _split_compound_model(model, chunk_size): for i in range(0, model.n_submodels, chunk_size): yield model[i:i + chunk_size] @staticmethod def _split_param_errs(param_err, nparam): for i in range(0, len(param_err), nparam): yield param_err[i:i + nparam] def _order_by_id(self, iterable): """ Reorder the list from group-id to source-id order. """ return [iterable[i] for i in self._group_results['ungroup_indices']] def _ungroup(self, iterable): """ Expand a list of lists (groups) and reorder in source-id order. """ iterable = _flatten(iterable) return self._order_by_id(iterable) def _get_fit_error_indices(self): indices = [] for index, fit_info in enumerate(self.fit_info['fit_infos']): ierr = fit_info.get('ierr', None) # check if in good flags defined by scipy if ierr is not None: # scipy.optimize.leastsq if ierr not in (1, 2, 3, 4): indices.append(index) else: # scipy.optimize.least_squares status = fit_info.get('status', None) if status is not None and status in (-1, 0): indices.append(index) return np.array(indices, dtype=int) def _parse_fit_results(self, group_models, group_fit_infos): """ Parse the fit results for each source or group of sources. """ psf_nsub = self.psf_model.n_submodels fit_models = [] fit_infos = [] fit_param_errs = [] nfitparam = len(self._param_maps['fit_params'].keys()) for model, fit_info in zip(group_models, group_fit_infos, strict=True): model_nsub = model.n_submodels npsf_models = model_nsub // psf_nsub # NOTE: param_cov/param_err are returned in the same order # as the model parameters param_cov = fit_info.get('param_cov', None) if param_cov is None: if nfitparam == 0: # model params are all fixed nfitparam = 3 # x_err, y_err, and flux_err are np.nan param_err = np.array([np.nan] * nfitparam * npsf_models) else: param_err = np.sqrt(np.diag(param_cov)) # model is for a single source (which may be compound) if npsf_models == 1: fit_models.append(model) fit_infos.append(fit_info) fit_param_errs.append(param_err) continue # model is a grouped model for multiple sources fit_models.extend(self._split_compound_model(model, psf_nsub)) fit_infos.extend([fit_info] * npsf_models) # views fit_param_errs.extend(self._split_param_errs(param_err, nfitparam)) if len(fit_models) != len(fit_infos): # pragma: no cover raise ValueError('fit_models and fit_infos have different lengths') # change the sorting from group_id to source id order fit_models = self._order_by_id(fit_models) fit_infos = self._order_by_id(fit_infos) fit_param_errs = np.array(self._order_by_id(fit_param_errs)) self.fit_info['fit_infos'] = fit_infos self.fit_info['fit_error_indices'] = self._get_fit_error_indices() self.fit_info['fit_param_errs'] = fit_param_errs return fit_models def _fit_sources(self, data, init_params, *, error=None, mask=None): if self.fitter_maxiters is not None: kwargs = {'maxiter': self.fitter_maxiters} else: kwargs = {} sources = init_params.group_by('group_id') ungroup_idx = np.argsort(sources['id'].value) self._group_results['ungroup_indices'] = ungroup_idx sources = sources.groups if self.progress_bar: # pragma: no cover desc = 'Fit source/group' sources = add_progress_bar(sources, desc=desc) # Save the fit_info results for these keys if they are present. # Some of these keys are returned only by some fitters. These # keys contain the fit residuals (fvec or fun), the parameter # covariance matrix (param_cov), and the fit status (ierr, # message) or (status). fit_info_keys = ('fvec', 'fun', 'param_cov', 'ierr', 'message', 'status') fit_models = [] fit_infos = [] nmodels = [] for sources_ in sources: # fit in group_id order nsources = len(sources_) nmodels.append([nsources] * nsources) psf_model = self._make_psf_model(sources_) yi, xi, cutout = self._define_fit_data(sources_, data, mask) weights = 1.0 / error[yi, xi] if error is not None else None with warnings.catch_warnings(): warnings.simplefilter('ignore', AstropyUserWarning) fit_model = self.fitter(psf_model, xi, yi, cutout, weights=weights, **kwargs) with contextlib.suppress(AttributeError): fit_model.clear_cache() fit_info = {} for key in fit_info_keys: value = self.fitter.fit_info.get(key, None) if value is not None: fit_info[key] = value fit_models.append(fit_model) fit_infos.append(fit_info) self._group_results['fit_infos'] = fit_infos self._group_results['nmodels'] = nmodels # split the groups and return objects in source-id order fit_models = self._parse_fit_results(fit_models, fit_infos) _fit_model_params = self._model_params_to_table(fit_models) fit_params = self._prepare_fit_results(_fit_model_params) self._fit_model_params = _fit_model_params # ungrouped self.fit_params = fit_params return fit_params def _calc_fit_metrics(self, results_tbl): # Keep cen_idx as a list because it can have NaNs with the ints. # If NaNs are present, turning it into an array will convert the # ints to floats, which cannot be used as slices. cen_idx = self._ungroup(self._group_results['psfcenter_indices']) split_index = [np.cumsum(npixfit)[:-1] for npixfit in self._group_results['npixfit']] # find the key with the fit residual (fitter dependent) finfo_keys = self._group_results['fit_infos'][0].keys() keys = ('fvec', 'fun') key = None for key_ in keys: if key_ in finfo_keys: key = key_ # SimplexLSQFitter if key is None: qfit = cfit = np.array([[np.nan]] * len(results_tbl)) return qfit, cfit fit_residuals = [] for idx, fit_info in zip(split_index, self._group_results['fit_infos'], strict=True): fit_residuals.extend(np.split(fit_info[key], idx)) fit_residuals = self._order_by_id(fit_residuals) with warnings.catch_warnings(): # ignore divide-by-zero if flux = 0 warnings.simplefilter('ignore', RuntimeWarning) flux_name = self._param_maps['model']['flux'] fluxcolname = self._param_maps['fit'][flux_name] qfit = [] cfit = [] for index, (residual, cen_idx_) in enumerate( zip(fit_residuals, cen_idx, strict=True)): flux_fit = results_tbl[fluxcolname][index] if isinstance(flux_fit, u.Quantity): flux_fit = flux_fit.value qfit.append(np.sum(np.abs(residual)) / flux_fit) if np.isnan(cen_idx_): # masked central pixel cen_residual = np.nan else: # find residual at center pixel; # astropy fitters compute residuals as # (model - data), thus need to negate the residual cen_residual = -residual[cen_idx_] cfit.append(cen_residual / flux_fit) return qfit, cfit def _define_flags(self, results_tbl, shape): flags = np.zeros(len(results_tbl), dtype=int) model_names = self._param_maps['model'] param_map = self._param_maps['fit'] xcolname = param_map[model_names['x']] ycolname = param_map[model_names['y']] fluxcolname = param_map[model_names['flux']] for index, row in enumerate(results_tbl): if row['npixfit'] < np.prod(self.fit_shape): flags[index] += 1 if (row[xcolname] < 0 or row[ycolname] < 0 or row[xcolname] > shape[1] or row[ycolname] > shape[0]): flags[index] += 2 if row[fluxcolname] <= 0: flags[index] += 4 flags[self.fit_info['fit_error_indices']] += 8 try: for index, fit_info in enumerate(self.fit_info['fit_infos']): if fit_info['param_cov'] is None: flags[index] += 16 except KeyError: pass # add flag = 32 if x or y fitted value is at the bounds if self.xy_bounds is not None: xcolname = self._param_maps['model']['x'] ycolname = self._param_maps['model']['y'] for index, row in enumerate(self._fit_model_params): x_bounds = row[f'{xcolname}_bounds'] x_bounds = np.array([i for i in x_bounds if i is not None]) y_bounds = row[f'{ycolname}_bounds'] y_bounds = np.array([i for i in y_bounds if i is not None]) dx = x_bounds - row[xcolname] dy = y_bounds - row[ycolname] if np.any(dx == 0) or np.any(dy == 0): flags[index] += 32 return flags def _prepare_fit_inputs(self, data, *, mask=None, error=None, init_params=None): """ Prepare inputs for PSF fitting. Tasks: * Checks array input shapes and units. * Calculates a total mask * Validates inputs for init_params and aperture_radius * Prepares initial parameters table - Runs source finder if needed - Runs aperture photometry if needed - Runs local background estimation if needed - Groups sources if needed """ (data, error), unit = process_quantities((data, error), ('data', 'error')) self.data_unit = unit data = self._validate_array(data, 'data') error = self._validate_array(error, 'error', data_shape=data.shape) mask = self._validate_array(mask, 'mask', data_shape=data.shape) mask = self._make_mask(data, mask) init_params = self._validate_init_params(init_params) # copies fluxcol = self._param_maps['init_cols']['flux'] if (self.aperture_radius is None and (init_params is None or fluxcol not in init_params.colnames)): raise ValueError('aperture_radius must be defined if init_params ' 'is not input or if a flux column is not in ' 'init_params') init_params = self._prepare_init_params(data, mask, init_params) if init_params is None: # no sources detected by finder return None self._check_init_positions(init_params, data.shape) self.init_params = init_params _, counts = np.unique(init_params['group_id'], return_counts=True) if max(counts) > 25: warnings.warn('Some groups have more than 25 sources. Fitting ' 'such groups may take a long time and be ' 'error-prone. You may want to consider using ' 'different `SourceGrouper` parameters or ' 'changing the "group_id" column in "init_params".', AstropyUserWarning) return data, mask, error, init_params def __call__(self, data, *, mask=None, error=None, init_params=None): """ Perform PSF photometry. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array on which to perform photometry. Invalid data values (i.e., NaN or inf) are automatically masked. mask : 2D bool `~numpy.ndarray`, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. error : 2D `~numpy.ndarray`, optional The pixel-wise 1-sigma errors of the input ``data``. ``error`` is assumed to include *all* sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. If ``data`` is a `~astropy.units.Quantity` array, then ``error`` must also be a `~astropy.units.Quantity` array with the same units. init_params : `~astropy.table.Table` or `None`, optional A table containing the initial guesses of the model parameters (e.g., x, y, flux) for each source. If the x and y values are not input, then the ``finder`` keyword must be defined. If the flux values are not input, then the initial fluxes will be measured using the ``aperture_radius`` keyword, which must be defined. Note that the initial flux values refer to the model flux parameters and are not corrected for local background values (computed using ``localbkg_estimator`` or input in a ``local_bkg`` column) The allowed column names are: * ``x_init``, ``xinit``, ``x``, ``x_0``, ``x0``, ``xcentroid``, ``x_centroid``, ``x_peak``, ``xcen``, ``x_cen``, ``xpos``, ``x_pos``, ``x_fit``, and ``xfit``. * ``y_init``, ``yinit``, ``y``, ``y_0``, ``y0``, ``ycentroid``, ``y_centroid``, ``y_peak``, ``ycen``, ``y_cen``, ``ypos``, ``y_pos``, ``y_fit``, and ``yfit``. * ``flux_init``, ``fluxinit``, ``flux``, ``flux_0``, ``flux0``, ``flux_fit``, ``fluxfit``, ``source_sum``, ``segment_flux``, and ``kron_flux``. * If the PSF model has additional free parameters that are fit, they can be included in the table. The column names must match the parameter names in the PSF model. They can also be suffixed with either the "_init" or "_fit" suffix. The suffix search order is "_init", "" (no suffix), and "_fit". For example, if the PSF model has an additional parameter named "sigma", then the allowed column names are: "sigma_init", "sigma", and "sigma_fit". If the column name is not found in the table, then the default value from the PSF model will be used. The parameter names are searched in the input table in the above order, stopping at the first match. If ``data`` is a `~astropy.units.Quantity` array, then the initial flux values in this table must also must also have compatible units. The table can also have ``group_id`` and ``local_bkg`` columns. If ``group_id`` is input, the values will be used and ``grouper`` keyword will be ignored. If ``local_bkg`` is input, those values will be used and the ``localbkg_estimator`` will be ignored. If ``data`` has units, then the ``local_bkg`` values must have the same units. Returns ------- table : `~astropy.table.QTable` An astropy table with the PSF-fitting results. The table will contain the following columns: * ``id`` : unique identification number for the source * ``group_id`` : unique identification number for the source group * ``group_size`` : the total number of sources that were simultaneously fit along with the given source * ``x_init``, ``x_fit``, ``x_err`` : the initial, fit, and error of the source x center * ``y_init``, ``y_fit``, ``y_err`` : the initial, fit, and error of the source y center * ``flux_init``, ``flux_fit``, ``flux_err`` : the initial, fit, and error of the source flux * ``npixfit`` : the number of unmasked pixels used to fit the source * ``qfit`` : a quality-of-fit metric defined as the the sum of the absolute value of the fit residuals divided by the fit flux * ``cfit`` : a quality-of-fit metric defined as the fit residual in the initial central pixel value divided by the fit flux. NaN values indicate that the central pixel was masked. * ``flags`` : bitwise flag values - 1 : one or more pixels in the ``fit_shape`` region were masked - 2 : the fit x and/or y position lies outside of the input data - 4 : the fit flux is less than or equal to zero - 8 : the fitter may not have converged. In this case, you can try increasing the maximum number of fit iterations using the ``fitter_maxiters`` keyword. - 16 : the fitter parameter covariance matrix was not returned - 32 : the fit x or y position is at the bounded value """ if isinstance(data, NDData): data_ = data.data if data.unit is not None: data_ <<= data.unit mask = data.mask unc = data.uncertainty if unc is not None: error = unc.represent_as(StdDevUncertainty).quantity if error.unit is u.dimensionless_unscaled: error = error.value else: error = error.to(data.unit) return self.__call__(data_, mask=mask, error=error, init_params=init_params) # reset results from previous runs self._reset_results() # Prepare fit inputs, including defining the initial source # parameters. This also runs the source finder, aperture # photometry, local background estimator, and source grouper, if # needed. fit_inputs = self._prepare_fit_inputs(data, mask=mask, error=error, init_params=init_params) if fit_inputs is None: return None # fit the sources data, mask, error, init_params = fit_inputs fit_params = self._fit_sources(data, init_params, error=error, mask=mask) # stack initial and fit params to create output table results_tbl = join(init_params, fit_params) npixfit = np.array(self._ungroup(self._group_results['npixfit'])) results_tbl['npixfit'] = npixfit nmodels = np.array(self._ungroup(self._group_results['nmodels'])) index = results_tbl.index_column('group_id') + 1 results_tbl.add_column(nmodels, name='group_size', index=index) qfit, cfit = self._calc_fit_metrics(results_tbl) results_tbl['qfit'] = qfit results_tbl['cfit'] = cfit results_tbl['flags'] = self._define_flags(results_tbl, data.shape) meta = _get_meta() attrs = ('fit_shape', 'fitter_maxiters', 'aperture_radius', 'progress_bar') for attr in attrs: meta[attr] = getattr(self, attr) results_tbl.meta = meta if len(self.fit_info['fit_error_indices']) > 0: warnings.warn('One or more fit(s) may not have converged. Please ' 'check the "flags" column in the output table.', AstropyUserWarning) # convert results from defaultdict to dict self.fit_info = dict(self.fit_info) self.results = results_tbl return results_tbl def make_model_image(self, shape, *, psf_shape=None, include_localbkg=False): return ModelImageMixin.make_model_image( self, shape, psf_shape=psf_shape, include_localbkg=include_localbkg) def make_residual_image(self, data, *, psf_shape=None, include_localbkg=False): return ModelImageMixin.make_residual_image( self, data, psf_shape=psf_shape, include_localbkg=include_localbkg) class IterativePSFPhotometry(ModelImageMixin): """ Class to iteratively perform PSF photometry. This is a convenience class that iteratively calls the `PSFPhotometry` class to perform PSF photometry on an input image. It can be useful for crowded fields where faint stars are very close to bright stars and are not detected in the first pass of PSF photometry. For complex cases, one may need to manually run `PSFPhotometry` in an iterative manner and inspect the residual image after each iteration. Parameters ---------- psf_model : 2D `astropy.modeling.Model` The PSF model to fit to the data. The model must have parameters named ``x_0``, ``y_0``, and ``flux``, corresponding to the center (x, y) position and flux, or it must have 'x_name', 'y_name', and 'flux_name' attributes that map to the x, y, and flux parameters (i.e., a model output from `make_psf_model`). The model must be two-dimensional such that it accepts 2 inputs (e.g., x and y) and provides 1 output. fit_shape : int or length-2 array_like The rectangular shape around the center of a star that will be used to define the PSF-fitting data. If ``fit_shape`` is a scalar then a square shape of size ``fit_shape`` will be used. If ``fit_shape`` has two elements, they must be in ``(ny, nx)`` order. Each element of ``fit_shape`` must be an odd number. In general, ``fit_shape`` should be set to a small size (e.g., ``(5, 5)``) that covers the region with the highest flux signal-to-noise. finder : callable or `~photutils.detection.StarFinderBase` A callable used to identify stars in an image. The ``finder`` must accept a 2D image as input and return a `~astropy.table.Table` containing the x and y centroid positions. These positions are used as the starting points for the PSF fitting. The allowed ``x`` column names are (same suffix for ``y``): ``'x_init'``, ``'xinit'``, ``'x'``, ``'x_0'``, ``'x0'``, ``'xcentroid'``, ``'x_centroid'``, ``'x_peak'``, ``'xcen'``, ``'x_cen'``, ``'xpos'``, ``'x_pos'``, ``'x_fit'``, and ``'xfit'``. If `None`, then the initial (x, y) model positions must be input using the ``init_params`` keyword when calling the class. The (x, y) values in ``init_params`` override this keyword *only for the first iteration*. If this class is run on an image that has units (i.e., a `~astropy.units.Quantity` array), then certain ``finder`` keywords (e.g., ``threshold``) must have the same units. Please see the documentation for the specific ``finder`` class for more information. grouper : `~photutils.psf.SourceGrouper` or callable or `None`, optional A callable used to group stars. Typically, grouped stars are those that overlap with their neighbors. Stars that are grouped are fit simultaneously. The ``grouper`` must accept the x and y coordinates of the sources and return an integer array of the group id numbers (starting from 1) indicating the group in which a given source belongs. If `None`, then no grouping is performed, i.e. each source is fit independently. The ``group_id`` values in ``init_params`` override this keyword *only for the first iteration*. A warning is raised if any group size is larger than 25 sources. fitter : `~astropy.modeling.fitting.Fitter`, optional The fitter object used to perform the fit of the model to the data. fitter_maxiters : int, optional The maximum number of iterations in which the ``fitter`` is called for each source. 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 in that direction. maxiters : int, optional The maximum number of PSF-fitting/subtraction iterations to perform. mode : {'new', 'all'}, optional For the 'new' mode, `PSFPhotometry` is run in each iteration only on the new sources detected in the residual image. For the 'all' mode, `PSFPhotometry` is run in each iteration on all the detected sources (from all previous iterations) on the original, unsubtracted, data. For the 'all' mode, a source ``grouper`` must be input. See the Notes section for more details. localbkg_estimator : `~photutils.background.LocalBackground` or `None`, \ optional The object used to estimate the local background around each source. If `None`, then no local background is subtracted. The ``local_bkg`` values in ``init_params`` override this keyword. This option should be used with care, especially in crowded fields where the ``fit_shape`` of sources overlap (see Notes below). aperture_radius : float, optional The radius of the circular aperture used to estimate the initial flux of each source. If initial flux values are present in the ``init_params`` table, they will override this keyword *only for the first iteration*. sub_shape : `None`, int, or length-2 array_like The rectangular shape around the center of a star that will be used when subtracting the fitted PSF models. If ``sub_shape`` is a scalar then a square shape of size ``sub_shape`` will be used. If ``sub_shape`` has two elements, they must be in ``(ny, nx)`` order. Each element of ``sub_shape`` must be an odd number. If `None`, then ``sub_shape`` will be defined by the model bounding box. This keyword must be specified if the model does not have a ``bounding_box`` attribute. progress_bar : bool, optional Whether to display a progress bar when fitting the sources (or groups). The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. Notes ----- The data that will be fit for each source is defined by the ``fit_shape`` parameter. A cutout will be made around the initial center of each source with a shape defined by ``fit_shape``. The PSF model will be fit to the data in this region. The cutout region that is fit does not shift if the source center shifts during the fit iterations. Therefore, the initial source positions should be close to the true source positions. One way to ensure this is to use a ``finder`` to identify sources in the data. If the fitted positions are significantly different from the initial positions, one can rerun the `PSFPhotometry` class using the fit results as the input ``init_params``, which will change the fitted cutout region for each source. After calling `PSFPhotometry` on the data, it will have a ``fit_params`` attribute containing the fitted model parameters. This table can be used as the ``init_params`` input in a subsequent call to `PSFPhotometry`. If the returned model parameter errors are NaN, then either the fit did not converge, the model parameter was fixed, or the input ``fitter`` did not return parameter errors. For the later case, one can try a different fitter that may return parameter errors (e.g., `astropy.modeling.fitting.DogBoxLSQFitter` or `astropy.modeling.fitting.LMLSQFitter`). The local background value around each source is optionally estimated using the ``localbkg_estimator`` or obtained from the ``local_bkg`` column in the input ``init_params`` table. This local background is then subtracted from the data over the ``fit_shape`` region for each source before fitting the PSF model. For sources where their ``fit_shape`` regions overlap, the local background will effectively be subtracted twice in the overlapping ``fit_shape`` regions, even if the source ``grouper`` is input. This is not an issue if the sources are well-separated. However, for crowded fields, please use the ``localbkg_estimator`` (or ``local_bkg`` column in ``init_params``) with care. This class has two modes of operation: 'new' and 'all'. For both modes, `PSFPhotometry` is first run on the data, a residual image is created, and the source finder is run on the residual image to detect any new sources. In the 'new' mode, `PSFPhotometry` is then run on the residual image to fit the PSF model to the new sources. The process is repeated until no new sources are detected or a maximum number of iterations is reached. In the 'all' mode, a new source list combining the sources from first `PSFPhotometry` run and the new sources detected in the residual image is created. `PSFPhotometry` is then run on the original, unsubtracted, data with this combined source list. This allows the source ``grouper`` (which is required for the 'all' mode) to combine close sources to be fit simultaneously, improving the fit. Again, the process is repeated until no new sources are detected or a maximum number of iterations is reached. Care should be taken in defining the star groups. Simultaneously fitting very large star groups is computationally expensive and error-prone. Internally, source grouping requires the creation of a compound Astropy model. Due to the way compound Astropy models are currently constructed, large groups also require excessively large amounts of memory; this will hopefully be fixed in a future Astropy version. A warning will be raised if the number of sources in a group exceeds 25. """ def __init__(self, psf_model, fit_shape, finder, *, grouper=None, fitter=TRFLSQFitter(), fitter_maxiters=100, xy_bounds=None, maxiters=3, mode='new', localbkg_estimator=None, aperture_radius=None, sub_shape=None, progress_bar=False): if finder is None: raise ValueError('finder cannot be None for ' 'IterativePSFPhotometry.') if aperture_radius is None: raise ValueError('aperture_radius cannot be None for ' 'IterativePSFPhotometry.') self._psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, grouper=grouper, fitter=fitter, fitter_maxiters=fitter_maxiters, xy_bounds=xy_bounds, localbkg_estimator=localbkg_estimator, aperture_radius=aperture_radius, progress_bar=progress_bar) self.maxiters = self._validate_maxiters(maxiters) if mode not in ['new', 'all']: raise ValueError('mode must be "new" or "all".') if mode == 'all' and grouper is None: raise ValueError('grouper must be input for the "all" mode.') self.mode = mode self.sub_shape = sub_shape self.fit_results = [] def _reset_results(self): """ Reset these attributes for each __call__. """ self.fit_results = [] @staticmethod def _validate_maxiters(maxiters): if (not np.isscalar(maxiters) or maxiters <= 0 or ~np.isfinite(maxiters)): raise ValueError('maxiters must be a strictly-positive scalar') if maxiters != int(maxiters): raise ValueError('maxiters must be an integer') return maxiters @staticmethod def _emit_warnings(recorded_warnings): """ Emit unique warnings from a list of recorded warnings. Parameters ---------- recorded_warnings : list A list of recorded warnings. """ msgs = [] emit_warnings = [] for warning in recorded_warnings: if str(warning.message) not in msgs: msgs.append(str(warning.message)) emit_warnings.append(warning) for warning in emit_warnings: warnings.warn_explicit(warning.message, warning.category, warning.filename, warning.lineno) def _convert_finder_to_init(self, sources): """ Convert the output of the finder to a table with initial (x, y) position column names. """ xcol = self._psfphot._param_maps['init_cols']['x'] ycol = self._psfphot._param_maps['init_cols']['y'] sources = sources[('xcentroid', 'ycentroid')] sources.rename_column('xcentroid', xcol) sources.rename_column('ycentroid', ycol) return sources def _measure_init_fluxes(self, data, mask, sources): """ Measure initial fluxes for the new sources from the residual data. The fluxes are added in place to the input ``sources`` table. The fluxes are measured using aperture photometry with a circular aperture of radius ``aperture_radius``. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array on which to perform photometry. mask : 2D bool `~numpy.ndarray` A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. sources : `~astropy.table.Table` A table containing the initial (x, y) positions of the sources. Returns ------- sources : `~astropy.table.Table` The input ``sources`` table with the new flux column added. """ flux = self._psfphot._get_aper_fluxes(data, mask, sources) unit = getattr(data, 'unit', None) if unit is not None: flux <<= unit fluxcol = self._psfphot._param_maps['init_cols']['flux'] sources[fluxcol] = flux return sources def _create_init_params(self, data, mask, new_sources, orig_sources): """ Create the initial parameters table by combining the original and new sources. """ # rename the columns from the fit results init_params = self._psfphot._rename_init_columns( orig_sources, self._psfphot._param_maps, self._psfphot._find_column_name) for colname in init_params.colnames: if '_init' not in colname: init_params.remove_column(colname) # add initial fluxes for the new sources from the residual data new_sources = self._measure_init_fluxes(data, mask, new_sources) # add columns for any additional parameters that are fit for param_name, colname in self._psfphot._param_maps['init'].items(): if colname not in new_sources.colnames: new_sources[colname] = getattr(self._psfphot.psf_model, param_name) # combine original and new source tables new_sources.meta.pop('date', None) # prevent merge conflicts return vstack([orig_sources, new_sources]) def __call__(self, data, *, mask=None, error=None, init_params=None): """ Perform PSF photometry. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array on which to perform photometry. Invalid data values (i.e., NaN or inf) are automatically masked. mask : 2D bool `~numpy.ndarray`, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. error : 2D `~numpy.ndarray`, optional The pixel-wise 1-sigma errors of the input ``data``. ``error`` is assumed to include *all* sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. If ``data`` is a `~astropy.units.Quantity` array, then ``error`` must also be a `~astropy.units.Quantity` array with the same units. init_params : `~astropy.table.Table` or `None`, optional A table containing the initial guesses of the model parameters (e.g., x, y, flux) for each source *only for the first iteration*. If the x and y values are not input, then the ``finder`` will be used for all iterations. If the flux values are not input, then the initial fluxes will be measured using the ``aperture_radius`` keyword. Note that the initial flux values refer to the model flux parameters and are not corrected for local background values (computed using ``localbkg_estimator`` or input in a ``local_bkg`` column) The allowed column names are: * ``x_init``, ``xinit``, ``x``, ``x_0``, ``x0``, ``xcentroid``, ``x_centroid``, ``x_peak``, ``xcen``, ``x_cen``, ``xpos``, ``x_pos``, ``x_fit``, and ``xfit``. * ``y_init``, ``yinit``, ``y``, ``y_0``, ``y0``, ``ycentroid``, ``y_centroid``, ``y_peak``, ``ycen``, ``y_cen``, ``ypos``, ``y_pos``, ``y_fit``, and ``yfit``. * ``flux_init``, ``fluxinit``, ``flux``, ``flux_0``, ``flux0``, ``flux_fit``, ``fluxfit``, ``source_sum``, ``segment_flux``, and ``kron_flux``. * If the PSF model has additional free parameters that are fit, they can be included in the table. The column names must match the parameter names in the PSF model. They can also be suffixed with either the "_init" or "_fit" suffix. The suffix search order is "_init", "" (no suffix), and "_fit". For example, if the PSF model has an additional parameter named "sigma", then the allowed column names are: "sigma_init", "sigma", and "sigma_fit". If the column name is not found in the table, then the default value from the PSF model will be used. The default values from the PSF model will also be used for all iterations after the first. The parameter names are searched in the input table in the above order, stopping at the first match. If ``data`` is a `~astropy.units.Quantity` array, then the initial flux values in this table must also must also have compatible units. Returns ------- table : `~astropy.table.QTable` An astropy table with the PSF-fitting results. The table will contain the following columns: * ``id`` : unique identification number for the source * ``group_id`` : unique identification number for the source group * ``group_size`` : the total number of sources that were simultaneously fit along with the given source * ``iter_detected`` : the iteration number in which the source was detected * ``x_init``, ``x_fit``, ``x_err`` : the initial, fit, and error of the source x center * ``y_init``, ``y_fit``, ``y_err`` : the initial, fit, and error of the source y center * ``flux_init``, ``flux_fit``, ``flux_err`` : the initial, fit, and error of the source flux * ``npixfit`` : the number of unmasked pixels used to fit the source * ``qfit`` : a quality-of-fit metric defined as the absolute value of the sum of the fit residuals divided by the fit flux * ``cfit`` : a quality-of-fit metric defined as the fit residual in the initial central pixel value divided by the fit flux. NaN values indicate that the central pixel was masked. * ``flags`` : bitwise flag values - 1 : one or more pixels in the ``fit_shape`` region were masked - 2 : the fit x and/or y position lies outside of the input data - 4 : the fit flux is less than or equal to zero - 8 : the fitter may not have converged. In this case, you can try increasing the maximum number of fit iterations using the ``fitter_maxiters`` keyword. - 16 : the fitter parameter covariance matrix was not returned - 32 : the fit x or y position is at the bounded value """ if isinstance(data, NDData): data_ = data.data if data.unit is not None: data_ <<= data.unit mask = data.mask unc = data.uncertainty if unc is not None: error = unc.represent_as(StdDevUncertainty).quantity if error.unit is u.dimensionless_unscaled: error = error.value else: error = error.to(data.unit) return self.__call__(data_, mask=mask, error=error, init_params=init_params) # reset results from previous runs self._reset_results() with warnings.catch_warnings(record=True) as rwarn0: phot_tbl = self._psfphot(data, mask=mask, error=error, init_params=init_params) self.fit_results.append(deepcopy(self._psfphot)) # this needs to be run outside of the context manager to be able # to reemit any warnings if phot_tbl is None: self._emit_warnings(rwarn0) return None residual_data = data with warnings.catch_warnings(record=True) as rwarn1: phot_tbl['iter_detected'] = 1 if self.mode == 'all': iter_detected = np.ones(len(phot_tbl), dtype=int) iter_num = 2 while iter_num <= self.maxiters and phot_tbl is not None: residual_data = self._psfphot.make_residual_image( residual_data, psf_shape=self.sub_shape) # do not warn if no sources are found beyond the first # iteration with warnings.catch_warnings(): warnings.simplefilter('ignore', NoDetectionsWarning) new_sources = self._psfphot.finder(residual_data, mask=mask) if new_sources is None: # no new sources detected break finder_results = new_sources.copy() new_sources = self._convert_finder_to_init(new_sources) if self.mode == 'all': init_params = self._create_init_params( residual_data, mask, new_sources, self._psfphot.fit_params) residual_data = data # keep track of the iteration number in which the source # was detected current_iter = (np.ones(len(new_sources), dtype=int) * iter_num) iter_detected = np.concatenate((iter_detected, current_iter)) elif self.mode == 'new': # fit new sources on the residual data init_params = new_sources # remove any sources that do not overlap the data imask = self._psfphot._get_invalid_positions(init_params, data.shape) init_params = init_params[~imask] if self.mode == 'all': iter_detected = iter_detected[~imask] new_tbl = self._psfphot(residual_data, mask=mask, error=error, init_params=init_params) self._psfphot.finder_results = finder_results self.fit_results.append(deepcopy(self._psfphot)) if self.mode == 'all': new_tbl['iter_detected'] = iter_detected phot_tbl = new_tbl elif self.mode == 'new': # combine tables new_tbl['id'] += np.max(phot_tbl['id']) new_tbl['group_id'] += np.max(phot_tbl['group_id']) new_tbl['iter_detected'] = iter_num new_tbl.meta = {} # prevent merge conflicts on date phot_tbl = vstack([phot_tbl, new_tbl]) iter_num += 1 # move 'iter_detected' column phot_tbl = self._psfphot._move_column(phot_tbl, 'iter_detected', 'group_size') # emit unique warnings recorded_warnings = rwarn0 + rwarn1 self._emit_warnings(recorded_warnings) return phot_tbl def make_model_image(self, shape, *, psf_shape=None, include_localbkg=False): return ModelImageMixin.make_model_image( self, shape, psf_shape=psf_shape, include_localbkg=include_localbkg) def make_residual_image(self, data, *, psf_shape=None, include_localbkg=False): return ModelImageMixin.make_residual_image( self, data, psf_shape=psf_shape, include_localbkg=include_localbkg) def _flatten(iterable): """ Flatten a list of lists. """ return list(chain.from_iterable(iterable)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/simulation.py0000644000175100001660000001647014755160622020552 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides simulation utilities 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_params from photutils.utils._parameters import as_pair __all__ = ['make_psf_model_image'] def make_psf_model_image(shape, psf_model, n_sources, *, model_shape=None, min_separation=1, border_size=None, seed=0, progress_bar=False, **kwargs): """ Make an example image containing PSF model images. Source parameters are randomly generated using an optional ``seed``. Parameters ---------- shape : 2-tuple of int The shape of the output image. psf_model : 2D `astropy.modeling.Model` The PSF model. The model must have parameters named ``x_0``, ``y_0``, and ``flux``, corresponding to the center (x, y) position and flux, or it must have 'x_name', 'y_name', and 'flux_name' attributes that map to the x, y, and flux parameters (i.e., a model output from `make_psf_model`). The model must be two-dimensional such that it accepts 2 inputs (e.g., x and y) and provides 1 output. n_sources : int The number of sources to generate. If ``min_separation`` is too large, the number of requested sources may not fit within the given ``shape`` and therefore the number of sources generated may be less than ``n_sources``. model_shape : `None` or 2-tuple of int, optional The shape around the center (x, y) position that will used to evaluate the ``psf_model``. If `None`, then the shape will be determined from the ``psf_model`` bounding box (an error will be raised if the model does not have a bounding box). min_separation : float, optional The minimum separation between the centers of two sources. Note that if the minimum separation is too large, the number of sources generated may be less than ``n_sources``. border_size : `None`, tuple of 2 int, or int, optional The (ny, nx) size of the exclusion border around the image edges where no sources will be generated that have centers within the border region. If a single integer is provided, it will be used for both dimensions. If `None`, then a border size equal to half the (y, x) size of the evaluated PSF model (taking any oversampling into account) will be used. seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. progress_bar : bool, optional Whether to display a progress bar when creating the sources. The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. **kwargs Keyword arguments are accepted for additional model parameters. The values should be 2-tuples of the lower and upper bounds for the parameter range. The parameter values will be uniformly distributed between the lower and upper bounds, inclusively. If the parameter is not in the input ``psf_model`` parameter names, it will be ignored. Returns ------- data : 2D `~numpy.ndarray` The simulated image. table : `~astropy.table.Table` A table containing the (x, y, flux) parameters of the generated sources. The column names will correspond to the names of the input ``psf_model`` (x, y, flux) parameter names. The table will also contain an ``'id'`` column with unique source IDs. Examples -------- >>> from photutils.psf import CircularGaussianPRF, make_psf_model_image >>> shape = (150, 200) >>> psf_model = CircularGaussianPRF(fwhm=3.5) >>> n_sources = 10 >>> data, params = make_psf_model_image(shape, psf_model, n_sources, ... flux=(100, 250), ... min_separation=10, ... seed=0) >>> params['x_0'].info.format = '.4f' # optional format >>> params['y_0'].info.format = '.4f' >>> params['flux'].info.format = '.4f' >>> print(params) # doctest: +FLOAT_CMP id x_0 y_0 flux --- -------- -------- -------- 1 125.2010 72.3184 147.9522 2 57.6408 39.1380 128.1262 3 15.5391 115.4520 200.8790 4 11.0411 131.7530 129.2661 5 157.6417 43.6615 186.6532 6 175.9470 80.2172 190.3359 7 142.2274 132.7563 244.3635 8 108.0270 13.4284 110.8398 9 180.0533 106.0888 174.9959 10 158.1171 90.3260 211.6146 .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf import CircularGaussianPRF, make_psf_model_image shape = (150, 200) psf_model = CircularGaussianPRF(fwhm=3.5) n_sources = 10 data, params = make_psf_model_image(shape, psf_model, n_sources, flux=(100, 250), min_separation=10, seed=0) plt.imshow(data, origin='lower') .. plot:: :include-source: import matplotlib.pyplot as plt from photutils.psf import CircularGaussianPRF, make_psf_model_image shape = (150, 200) psf_model = CircularGaussianPRF(fwhm=3.5) n_sources = 10 data, params = make_psf_model_image(shape, psf_model, n_sources, flux=(100, 250), min_separation=10, seed=0, sigma=(1, 2)) plt.imshow(data, origin='lower') """ psf_params = _get_psf_model_params(psf_model) if model_shape is not None: model_shape = as_pair('model_shape', model_shape, lower_bound=(0, 1)) else: try: model_shape = _model_shape_from_bbox(psf_model) except ValueError as exc: raise ValueError('model_shape must be specified if the model ' 'does not have a bounding_box attribute') from exc if border_size is None: border_size = (np.array(model_shape) - 1) // 2 other_params = {} if kwargs: # include only kwargs that are not x, y, or flux for key, val in kwargs.items(): if key not in psf_model.param_names or key in psf_params[0:2]: continue # skip the x, y parameters other_params[key] = val x_name, y_name = psf_params[0:2] params = make_model_params(shape, n_sources, x_name=x_name, y_name=y_name, min_separation=min_separation, border_size=border_size, seed=seed, **other_params) data = make_model_image(shape, psf_model, params, model_shape=model_shape, x_name=x_name, y_name=y_name, progress_bar=progress_bar) return data, params ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1739907483.715927 photutils-2.2.0/photutils/psf/tests/0000755000175100001660000000000014755160634017151 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/tests/__init__.py0000644000175100001660000000000014755160622021245 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1739907483.717927 photutils-2.2.0/photutils/psf/tests/data/0000755000175100001660000000000014755160634020062 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/tests/data/STDPSF_ACSWFC_F814W_mock.fits0000644000175100001660000003410014755160622024637 0ustar00runnerdockerSIMPLE = 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==ŨĀî././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/tests/data/STDPSF_NRCA1_F150W_mock.fits0000644000175100001660000001320014755160622024464 0ustar00runnerdockerSIMPLE = 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>*+ˆ>ß././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/tests/data/STDPSF_NRCSW_F150W_mock.fits0000644000175100001660000005500014755160622024560 0ustar00runnerdockerSIMPLE = 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>,¨ķ>’”././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/tests/data/STDPSF_WFC3UV_F814W_mock.fits0000644000175100001660000002070014755160622024647 0ustar00runnerdockerSIMPLE = 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Ū././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/tests/data/STDPSF_WFPC2_F814W_mock.fits0000644000175100001660000002070014755160622024513 0ustar00runnerdockerSIMPLE = 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>å÷=į„1././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/tests/data/nircam_nrca1_f200w_fovp101_samp4_npsf16_mock.fits0000644000175100001660000003410014755160622031005 0ustar00runnerdockerSIMPLE = 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]?ŗÕ‚‘7././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/tests/data/nircam_nrcb4_f150w_fovp101_samp4_npsf1_mock.fits0000644000175100001660000002640014755160622030733 0ustar00runnerdockerSIMPLE = 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:?ē‹’É+_›././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/tests/test_epsf.py0000644000175100001660000002252314755160622021520 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the epsf module. """ import itertools import numpy as np import pytest from astropy.modeling.fitting import TRFLSQFitter from astropy.nddata import (InverseVariance, NDData, StdDevUncertainty, VarianceUncertainty) from astropy.stats import SigmaClip from astropy.table import Table from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_almost_equal from scipy.spatial import cKDTree from photutils.datasets import make_model_image from photutils.psf.epsf import EPSFBuilder, EPSFFitter from photutils.psf.epsf_stars import EPSFStars, extract_stars from photutils.psf.functional_models import CircularGaussianPRF class TestEPSFBuild: def setup_class(self): """ Create a simulated image for testing. """ shape = (750, 750) # define random star positions nstars = 100 rng = np.random.default_rng(0) xx = rng.uniform(low=0, high=shape[1], size=nstars) yy = rng.uniform(low=0, high=shape[0], size=nstars) # enforce a minimum separation min_dist = 25 coords = [(yy[0], xx[0])] for xxi, yyi in zip(xx, yy, strict=True): newcoord = [yyi, xxi] dist, _ = cKDTree([newcoord]).query(coords, 1) if np.min(dist) > min_dist: coords.append(newcoord) yy, xx = np.transpose(coords) zz = rng.uniform(low=0, high=200000, size=len(xx)) # define a table of model parameters self.fwhm = 4.7 sources = Table() sources['amplitude'] = zz sources['x_0'] = xx sources['y_0'] = yy sources['fwhm'] = np.zeros(len(xx)) + self.fwhm sources['theta'] = 0.0 psf_model = CircularGaussianPRF(fwhm=self.fwhm) self.data = make_model_image(shape, psf_model, sources) self.nddata = NDData(self.data) init_stars = Table() init_stars['x'] = xx.astype(int) init_stars['y'] = yy.astype(int) self.init_stars = init_stars def test_extract_stars(self): size = 25 match = 'were not extracted because their cutout region extended' with pytest.warns(AstropyUserWarning, match=match): stars = extract_stars(self.nddata, self.init_stars, size=size) assert len(stars) == 81 assert isinstance(stars, EPSFStars) assert isinstance(stars[0], EPSFStars) assert stars[0].data.shape == (size, size) def test_extract_stars_uncertainties(self): rng = np.random.default_rng(0) shape = self.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(self.nddata.data, uncertainty=uncertainty1) ndd2 = NDData(self.nddata.data, uncertainty=uncertainty2) ndd3 = NDData(self.nddata.data, uncertainty=uncertainty3) size = 25 match = 'were not extracted because their cutout region extended' with pytest.warns(AstropyUserWarning, match=match): ndd_inputs = (ndd1, ndd2, ndd3) outputs = [extract_stars(ndd_input, self.init_stars, size=size) for ndd_input in ndd_inputs] for stars in outputs: assert len(stars) == 81 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) match = 'One or more weight values is not finite' with pytest.warns(AstropyUserWarning, match=match): uncertainty = StdDevUncertainty(np.zeros(shape)) ndd = NDData(self.nddata.data, uncertainty=uncertainty) stars = extract_stars(ndd, self.init_stars[0:3], size=size) def test_epsf_build(self): """ This is an end-to-end test of EPSFBuilder on a simulated image. """ size = 25 oversampling = 4 match = 'were not extracted because their cutout region extended' with pytest.warns(AstropyUserWarning, match=match): stars = extract_stars(self.nddata, self.init_stars, size=size) epsf_builder = EPSFBuilder(oversampling=oversampling, maxiters=15, progress_bar=False, norm_radius=25, recentering_maxiters=15) epsf, fitted_stars = epsf_builder(stars) ref_size = (size * oversampling) + 1 assert epsf.data.shape == (ref_size, ref_size) y0 = (ref_size - 1) / 2 / oversampling y = np.arange(ref_size, dtype=float) / oversampling psf_model = CircularGaussianPRF(fwhm=self.fwhm) z = epsf.data x = psf_model.evaluate(y.reshape(-1, 1), y.reshape(1, -1), 1, y0, y0, self.fwhm) assert_allclose(z, x, rtol=1e-2, atol=1e-5) resid_star = fitted_stars[0].compute_residual_image(epsf) assert_almost_equal(np.sum(resid_star) / fitted_stars[0].flux, 0, decimal=3) def test_epsf_fitting_bounds(self): size = 25 oversampling = 4 match = 'were not extracted because their cutout region extended' with pytest.warns(AstropyUserWarning, match=match): stars = extract_stars(self.nddata, self.init_stars, size=size) epsf_builder = EPSFBuilder(oversampling=oversampling, maxiters=8, progress_bar=True, norm_radius=25, recentering_maxiters=5, fitter=EPSFFitter(fit_boxsize=31), smoothing_kernel='quadratic') # With a boxsize larger than the cutout we expect the fitting to # fail for all stars, due to star._fit_error_status match1 = 'The ePSF fitting failed for all stars' match2 = r'The star at .* cannot be fit because its fitting region ' with (pytest.raises(ValueError, match=match1), pytest.warns(AstropyUserWarning, match=match2)): epsf_builder(stars) def test_epsf_build_invalid_fitter(self): """ Test that the input fitter is an EPSFFitter instance. """ match = 'fitter must be an EPSFFitter instance' with pytest.raises(TypeError, match=match): EPSFBuilder(fitter=EPSFFitter, maxiters=3) with pytest.raises(TypeError, match=match): EPSFBuilder(fitter=TRFLSQFitter(), maxiters=3) with pytest.raises(TypeError, match=match): EPSFBuilder(fitter=TRFLSQFitter, maxiters=3) def test_epsfbuilder_inputs(): # 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]) # valid inputs EPSFBuilder(oversampling=6) EPSFBuilder(oversampling=[4, 6]) # invalid inputs for sigma_clip in [None, [], 'a']: match = 'sigma_clip must be an astropy.stats.SigmaClip instance' with pytest.raises(TypeError, match=match): EPSFBuilder(sigma_clip=sigma_clip) # valid inputs EPSFBuilder(sigma_clip=SigmaClip(sigma=2.5, cenfunc='mean', maxiters=2)) @pytest.mark.parametrize('oversamp', [3, 4]) def test_epsf_build_oversampling(oversamp): offsets = (np.arange(oversamp) * 1.0 / oversamp - 0.5 + 1.0 / (2.0 * oversamp)) xydithers = np.array(list(itertools.product(offsets, offsets))) xdithers = np.transpose(xydithers)[0] ydithers = np.transpose(xydithers)[1] nstars = oversamp**2 fwhm = 7.0 sources = Table() offset = 50 size = oversamp * offset + offset y, x = np.mgrid[0:oversamp, 0:oversamp] * offset + offset sources['amplitude'] = np.full((nstars,), 100.0) sources['x_0'] = x.ravel() + xdithers sources['y_0'] = y.ravel() + ydithers sources['fwhm'] = np.full((nstars,), fwhm) psf_model = CircularGaussianPRF(fwhm=fwhm) shape = (size, size) data = make_model_image(shape, psf_model, sources) nddata = NDData(data=data) stars_tbl = Table() stars_tbl['x'] = sources['x_0'] stars_tbl['y'] = sources['y_0'] stars = extract_stars(nddata, stars_tbl, size=25) epsf_builder = EPSFBuilder(oversampling=oversamp, maxiters=15, progress_bar=False, recentering_maxiters=20) epsf, _ = epsf_builder(stars) # input PSF shape size = epsf.data.shape[0] cen = (size - 1) / 2 fwhm2 = oversamp * fwhm m = CircularGaussianPRF(flux=1, x_0=cen, y_0=cen, fwhm=fwhm2) yy, xx = np.mgrid[0:size, 0:size] psf = m(xx, yy) assert_allclose(epsf.data, psf * epsf.data.sum(), atol=2.5e-4) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/tests/test_epsf_stars.py0000644000175100001660000000667514755160622022746 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the epsf_stars module. """ import numpy as np import pytest from astropy.modeling.models import Moffat2D from astropy.nddata import NDData from astropy.table import Table from numpy.testing import assert_allclose from photutils.psf.epsf_stars import EPSFStars, extract_stars from photutils.psf.functional_models import CircularGaussianPRF from photutils.psf.image_models import ImagePSF class TestExtractStars: def setup_class(self): stars_tbl = Table() stars_tbl['x'] = [15, 15, 35, 35] stars_tbl['y'] = [15, 35, 40, 10] self.stars_tbl = stars_tbl yy, xx = np.mgrid[0:51, 0:55] self.data = np.zeros(xx.shape) for (xi, yi) in zip(stars_tbl['x'], stars_tbl['y'], strict=True): m = Moffat2D(100, xi, yi, 3, 3) self.data += m(xx, yy) self.nddata = NDData(data=self.data) def test_extract_stars(self): size = 11 stars = extract_stars(self.nddata, self.stars_tbl, size=size) assert len(stars) == 4 assert isinstance(stars, EPSFStars) assert isinstance(stars[0], EPSFStars) assert stars[0].data.shape == (size, size) assert stars.n_stars == stars.n_all_stars assert stars.n_stars == stars.n_good_stars assert stars.center.shape == (len(stars), 2) def test_extract_stars_inputs(self): match = 'data must be a single NDData or list of NDData objects' with pytest.raises(TypeError, match=match): extract_stars(np.ones(3), self.stars_tbl) match = 'catalogs must be a single Table or list of Table objects' with pytest.raises(TypeError, match=match): extract_stars(self.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(self.nddata, [self.stars_tbl, self.stars_tbl]) match = 'the catalog must have a "skycoord" column' with pytest.raises(ValueError, match=match): extract_stars([self.nddata, self.nddata], self.stars_tbl) def test_epsf_star_residual_image(): """ Test to ensure ``compute_residual_image`` gives correct residuals. """ size = 100 yy, xx, = np.mgrid[0:size + 1, 0:size + 1] / 4 gmodel = 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) # As current EPSFStar instances cannot accept CircularGaussianPRF # as input, we have to accept some loss of precision from the # conversion to ePSF, and spline fitting (twice), so assert_allclose # cannot be more precise than 0.001 currently. assert_allclose(np.sum(residual), 0.0, atol=1.0e-3, rtol=1e-3) def test_stars_pickleable(): """ Verify that EPSFStars can be successfully pickled/unpickled for use multiprocessing. """ from multiprocessing.reduction import ForkingPickler # Doesn't need to actually contain anything useful stars = EPSFStars([1]) # This should not blow up ForkingPickler.loads(ForkingPickler.dumps(stars)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/tests/test_functional_models.py0000644000175100001660000002174414755160622024274 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the functional_models module. """ import astropy.units as u import numpy as np import pytest from astropy.modeling.fitting import TRFLSQFitter from astropy.stats import gaussian_fwhm_to_sigma from numpy.testing import assert_allclose from photutils.psf import (AiryDiskPSF, CircularGaussianPRF, CircularGaussianPSF, CircularGaussianSigmaPRF, GaussianPRF, GaussianPSF, MoffatPSF) def make_gaussian_models(name): flux = 71.4 x_0 = 24.3 y_0 = 25.2 x_fwhm = 10.1 y_fwhm = 5.82 theta = 21.7 flux_i = 50 x_0_i = 20 y_0_i = 30 x_fwhm_i = 15 y_fwhm_i = 8 theta_i = 31 if name == 'GaussianPSF': model = GaussianPSF(flux=flux, x_0=x_0, y_0=y_0, x_fwhm=x_fwhm, y_fwhm=y_fwhm, theta=theta) model_init = GaussianPSF(flux=flux_i, x_0=x_0_i, y_0=y_0_i, x_fwhm=x_fwhm_i, y_fwhm=y_fwhm_i, theta=theta_i) elif name == 'GaussianPRF': model = GaussianPRF(flux=flux, x_0=x_0, y_0=y_0, x_fwhm=x_fwhm, y_fwhm=y_fwhm, theta=theta) model_init = GaussianPRF(flux=flux_i, x_0=x_0_i, y_0=y_0_i, x_fwhm=x_fwhm_i, y_fwhm=y_fwhm_i, theta=theta_i) elif name == 'CircularGaussianPSF': model = CircularGaussianPSF(flux=flux, x_0=x_0, y_0=y_0, fwhm=x_fwhm) model_init = CircularGaussianPSF(flux=flux_i, x_0=x_0_i, y_0=y_0_i, fwhm=x_fwhm_i) elif name == 'CircularGaussianPRF': model = CircularGaussianPRF(flux=flux, x_0=x_0, y_0=y_0, fwhm=x_fwhm) model_init = CircularGaussianPRF(flux=flux_i, x_0=x_0_i, y_0=y_0_i, fwhm=x_fwhm_i) elif name == 'CircularGaussianSigmaPRF': model = CircularGaussianSigmaPRF(flux=flux, x_0=x_0, y_0=y_0, sigma=x_fwhm / 2.35) model_init = CircularGaussianSigmaPRF(flux=flux_i, x_0=x_0_i, y_0=y_0_i, sigma=x_fwhm_i / 2.35) else: raise ValueError('invalid model name') return model, model_init def gaussian_tests(name, use_units): model, model_init = make_gaussian_models(name) fixed_types = ('fwhm', 'sigma', 'theta') for param in model.param_names: for fixed_type in fixed_types: if fixed_type in param: tparam = getattr(model_init, param) tparam.fixed = False yy, xx = np.mgrid[0:51, 0:51] if use_units: unit = u.m xx <<= unit yy <<= unit unit_params = ('x_0', 'y_0', 'x_fwhm', 'y_fwhm', 'fwhm', 'sigma') for param in model.param_names: if param in unit_params: tparam = getattr(model, param) tparam <<= unit setattr(model, param, tparam) data = model(xx, yy) if use_units: data = data.value assert_allclose(data.sum(), model.flux.value) try: assert_allclose(model.x_sigma, model.x_fwhm * gaussian_fwhm_to_sigma) assert_allclose(model.y_sigma, model.y_fwhm * gaussian_fwhm_to_sigma) except AttributeError: assert_allclose(model.sigma, model.fwhm * gaussian_fwhm_to_sigma) try: xsigma = model.x_sigma ysigma = model.y_sigma if isinstance(xsigma, u.Quantity): xsigma = xsigma.value ysigma = ysigma.value assert_allclose(model.amplitude * (2 * np.pi * xsigma * ysigma), model.flux) except AttributeError: sigma = model.sigma if isinstance(sigma, u.Quantity): sigma = sigma.value assert_allclose(model.amplitude * (2 * np.pi * sigma**2), model.flux) fitter = TRFLSQFitter() fit_model = fitter(model_init, xx, yy, data) assert_allclose(fit_model.x_0.value, model.x_0.value, rtol=1e-5) assert_allclose(fit_model.y_0.value, model.y_0.value, rtol=1e-5) try: assert_allclose(fit_model.x_fwhm.value, model.x_fwhm.value) assert_allclose(fit_model.y_fwhm.value, model.y_fwhm.value) assert_allclose(fit_model.theta.value, model.theta.value) except AttributeError: if name == 'CircularGaussianSigmaPRF': assert_allclose(fit_model.sigma.value, model.sigma.value) else: assert_allclose(fit_model.fwhm.value, model.fwhm.value) # test the model derivatives fit_model2 = fitter(model_init, xx, yy, data, estimate_jacobian=True) assert_allclose(fit_model2.x_0, fit_model.x_0) assert_allclose(fit_model2.y_0, fit_model.y_0) try: assert_allclose(fit_model2.x_fwhm, fit_model.x_fwhm) assert_allclose(fit_model2.y_fwhm, fit_model.y_fwhm) assert_allclose(fit_model2.theta, fit_model.theta) except AttributeError: assert_allclose(fit_model2.fwhm, fit_model.fwhm) if use_units and 'Circular' not in name: model.y_0 = model.y_0.value * u.s yy = yy.value * u.s match = 'Units .* inputs should match' with pytest.raises(u.UnitsError, match=match): fitter(model_init, xx, yy, data) @pytest.mark.parametrize('name', ['GaussianPSF', 'CircularGaussianPSF']) @pytest.mark.parametrize('use_units', [False, True]) def test_gaussian_psfs(name, use_units): gaussian_tests(name, use_units) @pytest.mark.parametrize('name', ['GaussianPRF', 'CircularGaussianPRF', 'CircularGaussianSigmaPRF']) @pytest.mark.parametrize('use_units', [False, True]) def test_gaussian_prfs(name, use_units): gaussian_tests(name, use_units) def test_gaussian_prf_sums(): """ Test that subpixel accuracy of Gaussian PRFs by checking the sum of pixels. """ model1 = GaussianPRF(x_0=0, y_0=0, x_fwhm=0.001, y_fwhm=0.001) model2 = CircularGaussianPRF(x_0=0, y_0=0, fwhm=0.001) model3 = CircularGaussianSigmaPRF(x_0=0, y_0=0, sigma=0.001) yy, xx = np.mgrid[-10:11, -10:11] for model in (model1, model2, model3): assert_allclose(model(xx, yy).sum(), 1.0) def test_gaussian_bounding_boxes(): model1 = GaussianPSF(x_0=0, y_0=0, x_fwhm=2, y_fwhm=3) model2 = GaussianPRF(x_0=0, y_0=0, x_fwhm=2, y_fwhm=3) xbbox = (-4.6712699, 4.6712699) ybbox = (-7.0069049, 7.0069048) for model in (model1, model2): assert_allclose(model.bounding_box, (xbbox, ybbox)) model3 = CircularGaussianPSF(x_0=0, y_0=0, fwhm=2) model4 = CircularGaussianPRF(x_0=0, y_0=0, fwhm=2) for model in (model3, model4): assert_allclose(model.bounding_box, (xbbox, xbbox)) model5 = CircularGaussianSigmaPRF(x_0=0, y_0=0, sigma=2) assert_allclose(model5.bounding_box, ((-11, 11), (-11, 11))) @pytest.mark.parametrize('use_units', [False, True]) def test_moffat_psf_model(use_units): model = MoffatPSF(flux=71.4, x_0=24.3, y_0=25.2, alpha=8.1, beta=7.2) model_init = MoffatPSF(flux=50, x_0=20, y_0=30, alpha=5, beta=4) model_init.alpha.fixed = False model_init.beta.fixed = False yy, xx = np.mgrid[0:51, 0:51] if use_units: unit = u.cm xx <<= unit yy <<= unit model.x_0 <<= unit model.y_0 <<= unit model.alpha <<= unit data = model(xx, yy) assert_allclose(data.sum(), model.flux.value, rtol=5e-6) fwhm = 2 * model.alpha * np.sqrt(2**(1 / model.beta) - 1) assert_allclose(model.fwhm, fwhm) fitter = TRFLSQFitter() fit_model = fitter(model_init, xx, yy, data) assert_allclose(fit_model.x_0.value, model.x_0.value) assert_allclose(fit_model.y_0.value, model.y_0.value) assert_allclose(fit_model.alpha.value, model.alpha.value) assert_allclose(fit_model.beta.value, model.beta.value) # test bounding box model = MoffatPSF(x_0=0, y_0=0, alpha=1.0, beta=2.0) bbox = 12.871885058111655 assert_allclose(model.bounding_box, ((-bbox, bbox), (-bbox, bbox))) @pytest.mark.parametrize('use_units', [False, True]) def test_airydisk_psf_model(use_units): model = AiryDiskPSF(flux=71.4, x_0=24.3, y_0=25.2, radius=2.1) model_init = AiryDiskPSF(flux=50, x_0=23, y_0=27, radius=2.5) model_init.radius.fixed = False yy, xx = np.mgrid[0:51, 0:51] if use_units: unit = u.cm xx <<= unit yy <<= unit model.x_0 <<= unit model.y_0 <<= unit model.radius <<= unit data = model(xx, yy) assert_allclose(data.sum(), model.flux.value, rtol=0.015) fwhm = 0.8436659602162364 * model.radius assert_allclose(model.fwhm, fwhm) fitter = TRFLSQFitter() fit_model = fitter(model_init, xx, yy, data) assert_allclose(fit_model.x_0.value, model.x_0.value) assert_allclose(fit_model.y_0.value, model.y_0.value) assert_allclose(fit_model.radius.value, model.radius.value) # test bounding box model = AiryDiskPSF(x_0=0, y_0=0, radius=5) bbox = 42.18329801081182 assert_allclose(model.bounding_box, ((-bbox, bbox), (-bbox, bbox))) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/tests/test_gridded_models.py0000644000175100001660000003036514755160622023533 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the gridded_models module. """ import os.path as op from itertools import product import numpy as np import pytest from astropy.modeling.models import Gaussian2D from astropy.nddata import NDData from astropy.table import QTable from numpy.testing import assert_allclose, assert_equal from photutils.datasets import make_model_image from photutils.psf import GriddedPSFModel, STDPSFGrid from photutils.segmentation import SourceCatalog, detect_sources from photutils.utils._optional_deps import HAS_MATPLOTLIB # the first file has a single detector, the rest have multiple detectors STDPSF_FILENAMES = ('STDPSF_NRCA1_F150W_mock.fits', 'STDPSF_ACSWFC_F814W_mock.fits', 'STDPSF_NRCSW_F150W_mock.fits', 'STDPSF_WFC3UV_F814W_mock.fits', 'STDPSF_WFPC2_F814W_mock.fits') WEBBPSF_FILENAMES = ('nircam_nrca1_f200w_fovp101_samp4_npsf16_mock.fits', 'nircam_nrca1_f200w_fovp101_samp4_npsf4_mock.fits', 'nircam_nrca5_f444w_fovp101_samp4_npsf4_mock.fits', 'nircam_nrcb4_f150w_fovp101_samp4_npsf1_mock.fits') @pytest.fixture(name='psfmodel') def fixture_griddedpsf_data(): psfs = [] yy, xx = np.mgrid[0:101, 0:101] for i in range(16): theta = np.deg2rad(i * 10.0) gmodel = Gaussian2D(1, 50, 50, 10, 5, theta=theta) psfs.append(gmodel(xx, yy)) xgrid = [0, 40, 160, 200] ygrid = [0, 60, 140, 200] meta = {} meta['grid_xypos'] = list(product(xgrid, ygrid)) meta['oversampling'] = 4 nddata = NDData(psfs, meta=meta) return GriddedPSFModel(nddata) class TestGriddedPSFModel: """ Tests for GriddPSFModel. """ def test_gridded_psf_model(self, psfmodel): keys = ['grid_xypos', 'oversampling'] for key in keys: assert key in psfmodel.meta grid_xypos = psfmodel.grid_xypos assert len(grid_xypos) == 16 assert_equal(psfmodel.oversampling, [4, 4]) assert_equal(psfmodel.meta['oversampling'], psfmodel.oversampling) assert psfmodel.data.shape == (16, 101, 101) idx = np.lexsort((grid_xypos[:, 0], grid_xypos[:, 1])) xypos = grid_xypos[idx] assert_allclose(xypos, grid_xypos) 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_eval_outside_grid(self, psfmodel): y, x = np.mgrid[-50:50, -50:50] psf1 = psfmodel.evaluate(x=x, y=y, flux=100, x_0=0, y_0=0) y, x = np.mgrid[-60:40, -60:40] psf2 = psfmodel.evaluate(x=x, y=y, flux=100, x_0=-10, y_0=-10) assert_allclose(psf1, psf2) y, x = np.mgrid[150:250, 150:250] psf3 = psfmodel.evaluate(x=x, y=y, flux=100, x_0=200, y_0=200) y, x = np.mgrid[170:270, 170:270] psf4 = psfmodel.evaluate(x=x, y=y, flux=100, x_0=220, y_0=220) assert_allclose(psf3, psf4) def test_gridded_psf_model_invalid_inputs(self): data = np.ones((4, 5, 5)) # check if NDData match = 'data must be an NDData instance' with pytest.raises(TypeError, match=match): GriddedPSFModel(data) # check PSF data dimension match = 'The NDData data attribute must be a 3D numpy ndarray' with pytest.raises(ValueError, match=match): GriddedPSFModel(NDData(np.ones((3, 3)))) match = 'The length of the PSF x and y axes must both be at least 4' with pytest.raises(ValueError, match=match): GriddedPSFModel(NDData(np.ones((3, 3, 3)))) match = 'All elements of input data must be finite' data2 = np.ones((4, 5, 5)) data2[0, 2, 2] = np.nan with pytest.raises(ValueError, match=match): GriddedPSFModel(NDData(data2)) # check that grid_xypos is in meta meta = {'oversampling': 4} nddata = NDData(data, meta=meta) match = '"grid_xypos" must be in the nddata meta dictionary' with pytest.raises(ValueError, match=match): GriddedPSFModel(nddata) # check grid_xypos length meta = {'grid_xypos': [[0, 0], [1, 0], [1, 0]], 'oversampling': 4} nddata = NDData(data, meta=meta) match = 'length of grid_xypos must match the number of input ePSFs' with pytest.raises(ValueError, match=match): GriddedPSFModel(nddata) # check if grid_xypos is a regular grid meta = {'grid_xypos': [[0, 0], [1, 0], [1, 0], [3, 4]], 'oversampling': 4} nddata = NDData(data, meta=meta) match = 'grid_xypos must form a rectangular grid' with pytest.raises(ValueError, match=match): GriddedPSFModel(nddata) # 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.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 ' 7 with pytest.warns(AstropyDeprecationWarning): model_oversampled = FittableImageModel(im, oversampling=oversamp) assert_allclose(model_oversampled(0, 0), gmodel_old(0, 0)) assert_allclose(model_oversampled(1, 1), gmodel_old(1, 1)) assert_allclose(model_oversampled(-2, 1), gmodel_old(-2, 1)) assert_allclose(model_oversampled(0.5, 0.5), gmodel_old(0.5, 0.5), rtol=.001) assert_allclose(model_oversampled(-0.5, 1.75), gmodel_old(-0.5, 1.75), rtol=.001) # without oversampling the same tests should fail except for at # the origin with pytest.warns(AstropyDeprecationWarning): model_wrongsampled = FittableImageModel(im) assert_allclose(model_wrongsampled(0, 0), gmodel_old(0, 0)) assert not np.allclose(model_wrongsampled(1, 1), gmodel_old(1, 1)) assert not np.allclose(model_wrongsampled(-2, 1), gmodel_old(-2, 1)) assert not np.allclose(model_wrongsampled(0.5, 0.5), gmodel_old(0.5, 0.5), rtol=.001) assert not np.allclose(model_wrongsampled(-0.5, 1.75), gmodel_old(-0.5, 1.75), rtol=.001) def test_centering_oversampled(self, gmodel_old): oversamp = 3 yy, xx = np.mgrid[-3:3.00001:(1 / oversamp), -3:3.00001:(1 / oversamp)] with pytest.warns(AstropyDeprecationWarning): model_oversampled = FittableImageModel(gmodel_old(xx, yy), oversampling=oversamp) valcen = gmodel_old(0, 0) val36 = gmodel_old(0.66, 0.66) assert_allclose(valcen, model_oversampled(0, 0)) assert_allclose(val36, model_oversampled(0.66, 0.66), rtol=1.0e-6) model_oversampled.x_0 = 2.5 model_oversampled.y_0 = -3.5 assert_allclose(valcen, model_oversampled(2.5, -3.5)) assert_allclose(val36, model_oversampled(2.5 + 0.66, -3.5 + 0.66), rtol=1.0e-6) def test_oversampling_inputs(self): data = np.arange(30).reshape(5, 6) for oversampling in [4, (3, 3), (3, 4)]: with pytest.warns(AstropyDeprecationWarning): fim = FittableImageModel(data, oversampling=oversampling) if not hasattr(oversampling, '__len__'): _oversamp = float(oversampling) else: _oversamp = tuple(float(o) for o in oversampling) assert np.all(fim._oversampling == _oversamp) match = 'oversampling must be > 0' for oversampling in [-1, [-2, 4]]: with (pytest.raises(ValueError, match=match), pytest.warns(AstropyDeprecationWarning)): FittableImageModel(data, oversampling=oversampling) match = 'oversampling must have 1 or 2 elements' oversampling = (1, 4, 8) with (pytest.raises(ValueError, match=match), pytest.warns(AstropyDeprecationWarning)): FittableImageModel(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), pytest.warns(AstropyDeprecationWarning)): FittableImageModel(data, oversampling=oversampling) match = 'oversampling must have integer values' with (pytest.raises(ValueError, match=match), pytest.warns(AstropyDeprecationWarning)): FittableImageModel(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), pytest.warns(AstropyDeprecationWarning)): FittableImageModel(data, oversampling=oversampling) def test_epsfmodel_inputs(): data = np.array([[], []]) match = 'Image data array cannot be zero-sized' with (pytest.raises(ValueError, match=match), pytest.warns(AstropyDeprecationWarning)): EPSFModel(data) data = np.ones((5, 5), dtype=float) data[2, 2] = np.inf match = 'must be finite' with (pytest.raises(ValueError, match=match), pytest.warns(AstropyDeprecationWarning)): EPSFModel(data) data[2, 2] = np.nan with (pytest.raises(ValueError, match=match), pytest.warns(AstropyDeprecationWarning)): EPSFModel(data, flux=None) data[2, 2] = 1 match = 'oversampling must be > 0' for oversampling in [-1, [-2, 4]]: with (pytest.raises(ValueError, match=match), pytest.warns(AstropyDeprecationWarning)): EPSFModel(data, oversampling=oversampling) match = 'oversampling must have 1 or 2 elements' oversampling = (1, 4, 8) with (pytest.raises(ValueError, match=match), pytest.warns(AstropyDeprecationWarning)): EPSFModel(data, oversampling=oversampling) match = 'oversampling must have integer values' oversampling = 2.1 with (pytest.raises(ValueError, match=match), pytest.warns(AstropyDeprecationWarning)): EPSFModel(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), pytest.warns(AstropyDeprecationWarning)): EPSFModel(data, oversampling=oversampling) match = 'oversampling must be a finite value' for oversampling in [np.nan, (1, np.inf)]: with (pytest.raises(ValueError, match=match), pytest.warns(AstropyDeprecationWarning)): EPSFModel(data, oversampling=oversampling) origin = (1, 2, 3) match = 'Parameter "origin" must be either None or an iterable with' with (pytest.raises(TypeError, match=match), pytest.warns(AstropyDeprecationWarning)): EPSFModel(data, origin=origin) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/tests/test_model_helpers.py0000644000175100001660000003377714755160622023422 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the model_helpers module. """ import astropy.units as u import numpy as np import pytest from astropy.modeling.fitting import TRFLSQFitter from astropy.modeling.models import Const2D, Gaussian2D, Moffat2D from astropy.nddata import NDData from astropy.table import Table from astropy.utils.exceptions import AstropyDeprecationWarning from numpy.testing import assert_allclose, assert_equal from scipy.integrate import dblquad from photutils import datasets from photutils.detection import find_peaks from photutils.psf import (EPSFBuilder, PRFAdapter, extract_stars, grid_from_epsfs, make_psf_model) from photutils.psf.model_helpers import _integrate_model, _InverseShift def test_inverse_shift(): model = _InverseShift(10) assert model(1) == -9.0 assert model(-10) == -20.0 assert model.fit_deriv(10, 1)[0] == -1.0 def test_integrate_model(): model = Gaussian2D(1, 5, 5, 1, 1) * Const2D(0.0) integral = _integrate_model(model, x_name='x_mean_0', y_name='y_mean_0') assert integral == 0.0 integral = _integrate_model(model, x_name='x_mean_0', y_name='y_mean_0', use_dblquad=True) assert integral == 0.0 match = 'dx and dy must be > 0' with pytest.raises(ValueError, match=match): _integrate_model(model, x_name='x_mean_0', y_name='y_mean_0', dx=-10, dy=10) with pytest.raises(ValueError, match=match): _integrate_model(model, x_name='x_mean_0', y_name='y_mean_0', dx=10, dy=-10) match = 'subsample must be >= 1' with pytest.raises(ValueError, match=match): _integrate_model(model, x_name='x_mean_0', y_name='y_mean_0', subsample=-1) match = 'model x and y positions must be finite' model = Gaussian2D(1, np.inf, 5, 1, 1) with pytest.raises(ValueError, match=match): _integrate_model(model, x_name='x_mean', y_name='y_mean') @pytest.fixture(name='moffat_source', scope='module') def fixture_moffat_source(): model = Moffat2D(alpha=4.8) # this is the analytic value needed to get a total flux of 1 model.amplitude = (model.alpha - 1.0) / (np.pi * model.gamma**2) xx, yy = np.meshgrid(*([np.linspace(-2, 2, 100)] * 2)) return model, (xx, yy, model(xx, yy)) def test_moffat_fitting(moffat_source): """ Test fitting with a Moffat2D model. """ model, (xx, yy, data) = moffat_source # initial Moffat2D model close to the original guess_moffat = Moffat2D(x_0=0.1, y_0=-0.05, gamma=1.05, amplitude=model.amplitude * 1.06, alpha=4.75) fitter = TRFLSQFitter() fit = fitter(guess_moffat, xx, yy, data) assert_allclose(fit.parameters, model.parameters, rtol=0.01, atol=0.0005) # we set the tolerances in flux to be 2-3% because the guessed model # parameters are known to be wrong @pytest.mark.parametrize(('kwargs', 'tols'), [({'x_name': 'x_0', 'y_name': 'y_0', 'flux_name': None, 'normalize': True}, (1e-3, 0.02)), ({'x_name': None, 'y_name': None, 'flux_name': None, 'normalize': True}, (1e-3, 0.02)), ({'x_name': None, 'y_name': None, 'flux_name': None, 'normalize': False}, (1e-3, 0.03)), ({'x_name': 'x_0', 'y_name': 'y_0', 'flux_name': 'amplitude', 'normalize': False}, (1e-3, None))]) def test_make_psf_model(moffat_source, kwargs, tols): model, (xx, yy, data) = moffat_source # a close-but-wrong "guessed Moffat" guess_moffat = Moffat2D(x_0=0.1, y_0=-0.05, gamma=1.01, amplitude=model.amplitude * 1.01, alpha=4.79) if kwargs['normalize']: # definitely very wrong, so this ensures the renormalization # works guess_moffat.amplitude = 5.0 if kwargs['x_name'] is None: guess_moffat.x_0 = 0 if kwargs['y_name'] is None: guess_moffat.y_0 = 0 psf_model = make_psf_model(guess_moffat, **kwargs) fitter = TRFLSQFitter() fit_model = fitter(psf_model, xx, yy, data) xytol, fluxtol = tols if xytol is not None: assert np.abs(getattr(fit_model, fit_model.x_name)) < xytol assert np.abs(getattr(fit_model, fit_model.y_name)) < xytol if fluxtol is not None: assert np.abs(1.0 - getattr(fit_model, fit_model.flux_name)) < fluxtol # ensure the model parameters did not change assert fit_model[2].gamma == guess_moffat.gamma assert fit_model[2].alpha == guess_moffat.alpha if kwargs['flux_name'] is None: assert fit_model[2].amplitude == guess_moffat.amplitude def test_make_psf_model_units(): model = Moffat2D(amplitude=1.0 * u.Jy, x_0=25, y_0=25, alpha=4.8, gamma=3.1) model.amplitude = (model.amplitude.unit * (model.alpha - 1.0) / (np.pi * model.gamma**2)) # normalize to flux=1 psf_model = make_psf_model(model, x_name='x_0', y_name='y_0', normalize=True) yy, xx = np.mgrid[:51, :51] data1 = model(xx, yy) data2 = psf_model(xx, yy) assert_allclose(data1, data2) def test_make_psf_model_compound(): model = (Const2D(0.0) + Const2D(1.0) + Gaussian2D(1, 5, 5, 1, 1) * Const2D(1.0) * Const2D(1.0)) psf_model = make_psf_model(model, x_name='x_mean_2', y_name='y_mean_2', normalize=True) assert psf_model.x_name == 'x_mean_4' assert psf_model.y_name == 'y_mean_4' assert psf_model.flux_name == 'amplitude_7' def test_make_psf_model_inputs(): model = Gaussian2D(1, 5, 5, 1, 1) match = 'parameter name not found in the input model' with pytest.raises(ValueError, match=match): make_psf_model(model, x_name='x_mean_0', y_name='y_mean') with pytest.raises(ValueError, match=match): make_psf_model(model, x_name='x_mean', y_name='y_mean_10') def test_make_psf_model_integral(): model = Gaussian2D(1, 5, 5, 1, 1) * Const2D(0.0) match = 'Cannot normalize the model because the integrated flux is zero' with pytest.raises(ValueError, match=match): make_psf_model(model, x_name='x_mean_0', y_name='y_mean_0', normalize=True) def test_make_psf_model_offset(): """ Test to ensure the offset is in the correct direction. """ moffat = Moffat2D(x_0=0, y_0=0, alpha=4.8) psfmod1 = make_psf_model(moffat.copy(), x_name='x_0', y_name='y_0', normalize=False) psfmod2 = make_psf_model(moffat.copy(), normalize=False) moffat.x_0 = 10 psfmod1.x_0_2 = 10 psfmod2.offset_0 = 10 assert moffat(10, 0) == psfmod1(10, 0) == psfmod2(10, 0) == 1.0 @pytest.mark.remote_data class TestGridFromEPSFs: """ Tests for `photutils.psf.utils.grid_from_epsfs`. """ def setup_class(self, cutout_size=25): # make a set of 4 EPSF models self.cutout_size = cutout_size # make simulated image hdu = datasets.load_simulated_hst_star_image() data = hdu.data # break up the image into four quadrants q1 = data[0:500, 0:500] q2 = data[0:500, 500:1000] q3 = data[500:1000, 0:500] q4 = data[500:1000, 500:1000] # select some starts from each quadrant to use to build the epsf quad_stars = {'q1': {'data': q1, 'fiducial': (0., 0.), 'epsf': None}, 'q2': {'data': q2, 'fiducial': (1000., 1000.), 'epsf': None}, 'q3': {'data': q3, 'fiducial': (1000., 0.), 'epsf': None}, 'q4': {'data': q4, 'fiducial': (0., 1000.), 'epsf': None}} for q in ['q1', 'q2', 'q3', 'q4']: quad_data = quad_stars[q]['data'] peaks_tbl = find_peaks(quad_data, threshold=500.) # filter out sources near edge size = cutout_size hsize = (size - 1) / 2 x = peaks_tbl['x_peak'] y = peaks_tbl['y_peak'] mask = ((x > hsize) & (x < (quad_data.shape[1] - 1 - hsize)) & (y > hsize) & (y < (quad_data.shape[0] - 1 - hsize))) stars_tbl = Table() stars_tbl['x'] = peaks_tbl['x_peak'][mask] stars_tbl['y'] = peaks_tbl['y_peak'][mask] stars = extract_stars(NDData(quad_data), stars_tbl, size=cutout_size) epsf_builder = EPSFBuilder(oversampling=4, maxiters=3, progress_bar=False) epsf, _ = epsf_builder(stars) # set x_0, y_0 to fiducial point epsf.y_0 = quad_stars[q]['fiducial'][0] epsf.x_0 = quad_stars[q]['fiducial'][1] quad_stars[q]['epsf'] = epsf self.epsfs = [val['epsf'] for val in quad_stars.values()] self.grid_xypos = [val['fiducial'] for val in quad_stars.values()] def test_basic_test_grid_from_epsfs(self): psf_grid = grid_from_epsfs(self.epsfs) assert np.all(psf_grid.oversampling == self.epsfs[0].oversampling) assert psf_grid.data.shape == (4, psf_grid.oversampling[0] * 25 + 1, psf_grid.oversampling[1] * 25 + 1) def test_grid_xypos(self): """ Test both options for setting PSF locations. """ # default option x_0 and y_0s on input EPSFs psf_grid = grid_from_epsfs(self.epsfs) assert psf_grid.meta['grid_xypos'] == [(0.0, 0.0), (1000.0, 1000.0), (0.0, 1000.0), (1000.0, 0.0)] # or pass in a list grid_xypos = [(250.0, 250.0), (750.0, 750.0), (250.0, 750.0), (750.0, 250.0)] psf_grid = grid_from_epsfs(self.epsfs, grid_xypos=grid_xypos) assert psf_grid.meta['grid_xypos'] == grid_xypos def test_meta(self): """ Test the option for setting 'meta'. """ keys = ['grid_xypos', 'oversampling', 'fill_value'] # when 'meta' isn't provided, there should be just three keys psf_grid = grid_from_epsfs(self.epsfs) for key in keys: assert key in psf_grid.meta # when meta is provided, those new keys should exist and anything # in the list above should be overwritten meta = {'grid_xypos': 0.0, 'oversampling': 0.0, 'fill_value': -999, 'extra_key': 'extra'} psf_grid = grid_from_epsfs(self.epsfs, meta=meta) for key in [*keys, 'extra_key']: assert key in psf_grid.meta assert psf_grid.meta['grid_xypos'].sort() == self.grid_xypos.sort() assert_equal(psf_grid.meta['oversampling'], [4, 4]) assert psf_grid.meta['fill_value'] == 0.0 class TestPRFAdapter: """ Tests for PRFAdapter. """ def normalize_moffat(self, mof): # this is the analytic value needed to get a total flux of 1 mof = mof.copy() mof.amplitude = (mof.alpha - 1) / (np.pi * mof.gamma**2) return mof @pytest.mark.parametrize('adapterkwargs', [ {'xname': 'x_0', 'yname': 'y_0', 'fluxname': None, 'renormalize_psf': False}, {'xname': None, 'yname': None, 'fluxname': None, 'renormalize_psf': False}, {'xname': 'x_0', 'yname': 'y_0', 'fluxname': 'amplitude', 'renormalize_psf': False}]) def test_create_eval_prfadapter(self, adapterkwargs): mof = Moffat2D(gamma=1, alpha=4.8) with pytest.warns(AstropyDeprecationWarning): prf = PRFAdapter(mof, **adapterkwargs) # test that these work without errors prf.x_0 = 0.5 prf.y_0 = -0.5 prf.flux = 1.2 prf(0, 0) @pytest.mark.parametrize('adapterkwargs', [ {'xname': 'x_0', 'yname': 'y_0', 'fluxname': None, 'renormalize_psf': True}, {'xname': 'x_0', 'yname': 'y_0', 'fluxname': None, 'renormalize_psf': False}, {'xname': None, 'yname': None, 'fluxname': None, 'renormalize_psf': False}]) def test_prfadapter_integrates(self, adapterkwargs): mof = Moffat2D(gamma=1.5, alpha=4.8) if not adapterkwargs['renormalize_psf']: mof = self.normalize_moffat(mof) with pytest.warns(AstropyDeprecationWarning): prf1 = PRFAdapter(mof, **adapterkwargs) # first check that the PRF over a central grid ends up summing to the # integrand over the whole PSF xg, yg = np.meshgrid(*([(-1, 0, 1)] * 2)) evalmod = prf1(xg, yg) if adapterkwargs['renormalize_psf']: mof = self.normalize_moffat(mof) integrand, itol = dblquad(mof, -1.5, 1.5, lambda x: -1.5, lambda x: 1.5) assert_allclose(np.sum(evalmod), integrand, atol=itol * 10) @pytest.mark.parametrize('adapterkwargs', [ {'xname': 'x_0', 'yname': 'y_0', 'fluxname': None, 'renormalize_psf': False}, {'xname': None, 'yname': None, 'fluxname': None, 'renormalize_psf': False}]) def test_prfadapter_sizematch(self, adapterkwargs): mof1 = self.normalize_moffat(Moffat2D(gamma=1, alpha=4.8)) with pytest.warns(AstropyDeprecationWarning): prf1 = PRFAdapter(mof1, **adapterkwargs) # now try integrating over differently-sampled PRFs # and check that they match mof2 = self.normalize_moffat(Moffat2D(gamma=2, alpha=4.8)) with pytest.warns(AstropyDeprecationWarning): prf2 = PRFAdapter(mof2, **adapterkwargs) xg1, yg1 = np.meshgrid(*([(-0.5, 0.5)] * 2)) xg2, yg2 = np.meshgrid(*([(-1.5, -0.5, 0.5, 1.5)] * 2)) eval11 = prf1(xg1, yg1) eval22 = prf2(xg2, yg2) _, itol = dblquad(mof1, -2, 2, lambda x: -2, lambda x: 2) # it's a bit of a guess that the above itol is appropriate, but # it should be close assert_allclose(np.sum(eval11), np.sum(eval22), atol=itol * 100) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/tests/test_photometry.py0000644000175100001660000013416414755160622023002 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the photometry module. """ import astropy.units as u import numpy as np import pytest from astropy.modeling.fitting import (LMLSQFitter, SimplexLSQFitter, TRFLSQFitter) from astropy.modeling.models import Gaussian1D, Gaussian2D from astropy.nddata import NDData, StdDevUncertainty from astropy.table import QTable, Table from astropy.utils.exceptions import 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, PSFPhotometry, SourceGrouper, make_psf_model, make_psf_model_image) from photutils.utils.exceptions import NoDetectionsWarning @pytest.fixture(name='test_data') def fixture_test_data(): psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) model_shape = (9, 9) n_sources = 10 shape = (101, 101) data, true_params = make_psf_model_image(shape, psf_model, n_sources, model_shape=model_shape, flux=(500, 700), min_separation=10, seed=0) noise = make_noise_image(data.shape, mean=0, stddev=1, seed=0) data += noise error = np.abs(noise) return data, error, true_params 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 = 'localbkg_estimator must be a LocalBackground instance' localbkg = MMMBackground() with pytest.raises(ValueError, match=match): _ = PSFPhotometry(model, 1, localbkg_estimator=localbkg) match = 'aperture_radius must be a strictly-positive scalar' for radius in (0, -1, np.nan, np.inf): with pytest.raises(ValueError, match=match): _ = PSFPhotometry(model, 1, aperture_radius=radius) match = 'grouper must be a SourceGrouper instance' with pytest.raises(ValueError, match=match): _ = PSFPhotometry(model, (5, 5), grouper=1) match = 'data must be a 2D array' psfphot = PSFPhotometry(model, (3, 3)) with pytest.raises(ValueError, match=match): _ = psfphot(np.arange(3)) match = 'data and error must have the same shape.' 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_param must contain valid column names for the x and y ' 'source positions') tbl = Table() tbl['a'] = np.arange(3) data = np.ones((11, 11)) with pytest.raises(ValueError, match=match): _ = psfphot(data, init_params=tbl) # test no finder or init_params match = 'finder must be defined if init_params is not input' psfphot = PSFPhotometry(model, (3, 3), aperture_radius=5) data = np.ones((11, 11)) with pytest.raises(ValueError, match=match): _ = psfphot(data) # data has unmasked non-finite value match = 'Input data contains unmasked non-finite values' psfphot2 = PSFPhotometry(model, (3, 3), aperture_radius=3) init_params = Table() init_params['x_init'] = [1, 2] init_params['y_init'] = [1, 2] data = np.ones((11, 11)) data[5, 5] = np.nan with pytest.warns(AstropyUserWarning, match=match): _ = psfphot2(data, init_params=init_params) # mask is input, but data has unmasked non-finite value match = 'Input data contains unmasked non-finite values' data = np.ones((11, 11)) data[5, 5] = np.nan mask = np.zeros(data.shape, dtype=bool) mask[7, 7] = True with pytest.warns(AstropyUserWarning, match=match): _ = psfphot2(data, mask=mask, init_params=init_params) match = 'init_params local_bkg column contains non-finite values' tbl = Table() init_params['x_init'] = [1, 2] init_params['y_init'] = [1, 2] init_params['local_bkg'] = [0.1, np.inf] data = np.ones((11, 11)) with pytest.raises(ValueError, match=match): _ = psfphot(data, init_params=init_params) 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 keys = ('fit_infos', 'fit_error_indices') for key in keys: assert key in psfphot.fit_info # test that repeated calls reset the results phot = psfphot(data, error=error) assert len(psfphot.fit_info['fit_infos']) == len(phot) # test units unit = u.Jy finderu = DAOStarFinder(6.0 * unit, 2.0) psfphotu = PSFPhotometry(psf_model, fit_shape, finder=finderu, aperture_radius=4) photu = psfphotu(data * unit, error=error * unit) colnames = ('flux_init', 'flux_fit', 'flux_err', 'local_bkg') for col in colnames: assert photu[col].unit == unit resid_datau = psfphotu.make_residual_image(data << unit, psf_shape=fit_shape) assert resid_datau.unit == unit colnames = ('qfit', 'cfit') for col in colnames: assert not isinstance(col, u.Quantity) @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_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() localbkg_estimator = LocalBackground(5, 10, bkgstat) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, localbkg_estimator=localbkg_estimator) psfphot(data, error=error) psf_shape = (25, 25) model1 = psfphot.make_model_image(data.shape, psf_shape=psf_shape, include_localbkg=False) model2 = psfphot.make_model_image(data.shape, psf_shape=psf_shape, include_localbkg=True) resid1 = psfphot.make_residual_image(data, psf_shape=psf_shape, include_localbkg=False) resid2 = psfphot.make_residual_image(data, psf_shape=psf_shape, include_localbkg=True) x, y = 0, 100 assert model1[y, x] < 0.1 assert model2[y, x] > 9 assert resid1[y, x] > 9 assert resid2[y, x] < 0 x, y = 0, 80 assert model1[y, x] < 0.1 assert model2[y, x] > 18 assert resid1[y, x] > 9 assert resid2[y, x] < -9 @pytest.mark.parametrize('fit_stddev', [False, True]) def test_psf_photometry_compound_psfmodel(test_data, fit_stddev): """ Test compound models output from ``make_psf_model``. """ data, error, sources = test_data x_stddev = y_stddev = 1.2 psf_func = Gaussian2D(amplitude=1, x_mean=0, y_mean=0, x_stddev=x_stddev, y_stddev=y_stddev) psf_model = make_psf_model(psf_func, x_name='x_mean', y_name='y_mean') if fit_stddev: psf_model.x_stddev_2.fixed = False psf_model.y_stddev_2.fixed = False fit_shape = (5, 5) finder = DAOStarFinder(5.0, 3.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) phot = psfphot(data, error=error) assert isinstance(phot, QTable) assert len(phot) == len(sources) if fit_stddev: cols = ('x_stddev_2', 'y_stddev_2') suffixes = ('_init', '_fit', '_err') colnames = [col + suffix for suffix in suffixes for col in cols] for colname in colnames: assert colname in phot.colnames # test model and residual images psf_shape = (9, 9) model1 = psfphot.make_model_image(data.shape, psf_shape=psf_shape, include_localbkg=False) resid1 = psfphot.make_residual_image(data, psf_shape=psf_shape, include_localbkg=False) model2 = psfphot.make_model_image(data.shape, psf_shape=psf_shape, include_localbkg=True) resid2 = psfphot.make_residual_image(data, psf_shape=psf_shape, include_localbkg=True) assert model1.shape == data.shape assert model2.shape == data.shape assert resid1.shape == data.shape assert resid2.shape == data.shape assert_equal(data - model1, resid1) assert_equal(data - model2, resid2) # test with init_params init_params = psfphot.fit_params phot = psfphot(data, error=error, init_params=init_params) assert isinstance(phot, QTable) assert len(phot) == len(sources) if fit_stddev: cols = ('x_stddev_2', 'y_stddev_2') suffixes = ('_init', '_fit', '_err') colnames = [col + suffix for suffix in suffixes for col in cols] for colname in colnames: assert colname in phot.colnames # test results when fit does not converge (fitter_maxiters=3) match = r'One or more fit\(s\) may not have converged.' with pytest.warns(AstropyUserWarning, match=match): psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, fitter_maxiters=3) phot = psfphot(data, error=error) assert len(phot) == len(sources) @pytest.mark.parametrize('mode', ['new', 'all']) def test_iterative_psf_photometry_compound(mode): x_stddev = y_stddev = 1.7 psf_func = Gaussian2D(amplitude=1, x_mean=0, y_mean=0, x_stddev=x_stddev, y_stddev=y_stddev) psf_model = make_psf_model(psf_func, x_name='x_mean', y_name='y_mean') psf_model.x_stddev_2.fixed = False psf_model.y_stddev_2.fixed = False other_params = {psf_model.flux_name: (500, 700)} model_shape = (9, 9) n_sources = 10 shape = (101, 101) data, true_params = make_psf_model_image(shape, psf_model, n_sources, model_shape=model_shape, **other_params, min_separation=10, seed=0) noise = make_noise_image(data.shape, mean=0, stddev=1, seed=0) data += noise error = np.abs(noise) init_params = QTable() init_params['x'] = [54, 29, 80] init_params['y'] = [8, 26, 29] fit_shape = (5, 5) finder = DAOStarFinder(6.0, 3.0) grouper = SourceGrouper(min_separation=2) psfphot = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, grouper=grouper, aperture_radius=4, sub_shape=fit_shape, mode=mode, maxiters=2) phot = psfphot(data, error=error, init_params=init_params) assert isinstance(phot, QTable) assert len(phot) == len(true_params) cols = ('x_stddev_2', 'y_stddev_2') suffixes = ('_init', '_fit', '_err') colnames = [col + suffix for suffix in suffixes for col in cols] for colname in colnames: assert colname in phot.colnames # test model and residual images psf_shape = (9, 9) model1 = psfphot.make_model_image(data.shape, psf_shape=psf_shape, include_localbkg=False) resid1 = psfphot.make_residual_image(data, psf_shape=psf_shape, include_localbkg=False) model2 = psfphot.make_model_image(data.shape, psf_shape=psf_shape, include_localbkg=True) resid2 = psfphot.make_residual_image(data, psf_shape=psf_shape, include_localbkg=True) assert model1.shape == data.shape assert model2.shape == data.shape assert resid1.shape == data.shape assert resid2.shape == data.shape assert_equal(data - model1, resid1) assert_equal(data - model2, resid2) # test with init_params init_params = psfphot.fit_results[-1].fit_params phot = psfphot(data, error=error, init_params=init_params) assert isinstance(phot, QTable) assert len(phot) == len(true_params) cols = ('x_stddev_2', 'y_stddev_2') suffixes = ('_init', '_fit', '_err') colnames = [col + suffix for suffix in suffixes for col in cols] for colname in colnames: assert colname in phot.colnames def test_psf_photometry_mask(test_data): data, error, sources = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) # test np.ma.nomask phot = psfphot(data, error=error, mask=None) photm = psfphot(data, error=error, mask=np.ma.nomask) assert np.all(phot == photm) # masked near source at ~(63, 49) data_orig = data.copy() data = data.copy() data[55, 60:70] = np.nan match = 'Input data contains unmasked non-finite values' with pytest.warns(AstropyUserWarning, match=match): phot1 = psfphot(data, error=error, mask=None) assert len(phot1) == len(sources) mask = ~np.isfinite(data) phot2 = psfphot(data, error=error, mask=mask) assert np.all(phot1 == phot2) # unmasked NaN with mask not None match = 'Input data contains unmasked non-finite values' with pytest.warns(AstropyUserWarning, match=match): mask = ~np.isfinite(data) mask[55, 65] = False phot = psfphot(data, error=error, mask=mask) assert len(phot) == len(sources) # mask all True; finder returns no sources match = 'No sources were found' with pytest.warns(NoDetectionsWarning, match=match): mask = np.ones(data.shape, dtype=bool) psfphot(data, mask=mask) # completely masked source match = ('is completely masked. Remove the source from init_params ' 'or correct the input mask') init_params = QTable() init_params['x'] = [63] init_params['y'] = [49] mask = np.ones(data.shape, dtype=bool) with pytest.raises(ValueError, match=match): _ = psfphot(data, mask=mask, init_params=init_params) # masked central pixel init_params = QTable() init_params['x'] = [63] init_params['y'] = [49] mask = np.zeros(data.shape, dtype=bool) mask[49, 63] = True phot = psfphot(data_orig, mask=mask, init_params=init_params) assert len(phot) == 1 assert np.isnan(phot['cfit'][0]) # this should not raise a warning because the non-finite pixel was # explicitly masked psfphot = PSFPhotometry(psf_model, (3, 3), aperture_radius=3) data = np.ones((11, 11)) data[5, 5] = np.nan mask = np.zeros(data.shape, dtype=bool) mask[5, 5] = True init_params = Table() init_params['x_init'] = [1, 2] init_params['y_init'] = [1, 2] psfphot(data, mask=mask, init_params=init_params) 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 init_params is not input' 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) init_params = QTable() init_params['x'] = [-63] init_params['y'] = [-49] init_params['flux'] = [100] match = 'Some of the sources have no overlap with the data' with pytest.raises(ValueError, match=match): _ = psfphot(data, init_params=init_params) # check that the first matching column name is used init_params = QTable() x = 63 y = 49 flux = 680 init_params['x'] = [x] init_params['y'] = [y] init_params['flux'] = [flux] init_params['x_cen'] = [x + 0.1] init_params['y_cen'] = [y + 0.1] init_params['flux0'] = [flux + 0.1] phot = psfphot(data, error=error, init_params=init_params) assert isinstance(phot, QTable) assert len(phot) == 1 assert phot['x_init'][0] == x assert phot['y_init'][0] == y assert phot['flux_init'][0] == flux 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_localbkg=val) assert isinstance(im, u.Quantity) assert im.unit == unit resid = psfphot.make_residual_image(data2, psf_shape=fit_shape, include_localbkg=val) assert isinstance(resid, u.Quantity) assert resid.unit == unit # test invalid units colnames = ('flux', 'local_bkg') for col in colnames: init_params2 = init_params.copy() init_params2.remove_column('flux') init_params2.remove_column('local_bkg') init_params2[col] = [650 * u.Jy] match = 'column has units, but the input data does not have units' with pytest.raises(ValueError, match=match): _ = psfphot(data, error=error, init_params=init_params2) init_params2[col] = [650 * u.Jy] match = 'column has units that are incompatible with the input data' with pytest.raises(ValueError, match=match): _ = psfphot(data << u.m, init_params=init_params2) init_params2[col] = [650] match = 'The input data has units, but the init_params' with pytest.raises(ValueError, match=match): _ = psfphot(data << u.Jy, init_params=init_params2) def test_psf_photometry_init_params_columns(test_data): data, error, _ = test_data data = data.copy() psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder) xy_suffixes = ('_init', 'init', 'centroid', '_centroid', '_peak', '', 'cen', '_cen', 'pos', '_pos', '_0', '0') flux_cols = ['flux_init', 'flux_0', 'flux0', 'flux', 'source_sum', 'segment_flux', 'kron_flux'] pad = len(xy_suffixes) - len(flux_cols) flux_cols += flux_cols[0:pad] # pad to have same length as xy_suffixes xcols = ['x' + i for i in xy_suffixes] ycols = ['y' + i for i in xy_suffixes] phots = [] for xcol, ycol, fluxcol in zip(xcols, ycols, flux_cols, strict=True): init_params = QTable() init_params[xcol] = [42] init_params[ycol] = [36] init_params[fluxcol] = [680] phot = psfphot(data, error=error, init_params=init_params) assert isinstance(phot, QTable) assert len(phot) == 1 phots.append(phot) for phot in phots[1:]: assert_allclose(phot['x_fit'], phots[0]['x_fit']) assert_allclose(phot['y_fit'], phots[0]['y_fit']) assert_allclose(phot['flux_fit'], phots[0]['flux_fit']) def test_grouper(test_data): data, error, sources = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) grouper = SourceGrouper(min_separation=20) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, grouper=grouper, aperture_radius=4) phot = psfphot(data, error=error) assert isinstance(phot, QTable) assert len(phot) == len(sources) assert_equal(phot['group_id'], (1, 2, 3, 4, 5, 5, 5, 6, 6, 7)) assert_equal(phot['group_size'], (1, 1, 1, 1, 3, 3, 3, 2, 2, 1)) def test_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' with pytest.warns(AstropyUserWarning, match=match): psfphot = PSFPhotometry(psf_model, fit_shape, grouper=grouper) 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() localbkg_estimator = LocalBackground(5, 10, bkgstat) finder = DAOStarFinder(10.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, grouper=grouper, aperture_radius=4, localbkg_estimator=localbkg_estimator) phot = psfphot(data, error=error) assert np.count_nonzero(phot['local_bkg']) == len(sources) def test_fixed_params(test_data): data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) psf_model.x_0.fixed = True psf_model.y_0.fixed = True psf_model.flux.fixed = True fit_shape = (5, 5) finder = DAOStarFinder(6.0, 2.0) psfphot = PSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4) match = r'`bounds` must contain 2 elements' with pytest.raises(ValueError, match=match): psfphot(data, error=error) def test_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): _ = psfphot(data) assert len(psfphot.fit_info['fit_error_indices']) > 0 def test_fitter_no_maxiters_no_metrics(test_data): """ Test with a fitter that does not have a maxiters parameter and does not produce a residual array. """ data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) psf_model.flux.fixed = False fit_shape = (5, 5) fitter = SimplexLSQFitter() # does not produce residual array finder = DAOStarFinder(6.0, 2.0) match = '"maxiters" will be ignored because it is not accepted by' with pytest.warns(AstropyUserWarning, match=match): psfphot = PSFPhotometry(psf_model, fit_shape, fitter=fitter, finder=finder, aperture_radius=4) phot = psfphot(data, error=error) assert np.all(np.isnan(phot['qfit'])) assert np.all(np.isnan(phot['cfit'])) def test_xy_bounds(test_data): data, error, _ = test_data psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) init_params = QTable() init_params['x'] = [65] init_params['y'] = [51] xy_bounds = (1, 1) psfphot = PSFPhotometry(psf_model, fit_shape, finder=None, aperture_radius=4, xy_bounds=xy_bounds) phot = psfphot(data, error=error, init_params=init_params) assert len(phot) == len(init_params) assert_allclose(phot['x_fit'], 64.0) # at lower bound assert_allclose(phot['y_fit'], 50.0) # at lower bound psfphot2 = PSFPhotometry(psf_model, fit_shape, finder=None, aperture_radius=4, xy_bounds=1) phot2 = psfphot2(data, error=error, init_params=init_params) cols = ('x_fit', 'y_fit', 'flux_fit') for col in cols: assert np.all(phot[col] == phot2[col]) xy_bounds = (None, 1) psfphot = PSFPhotometry(psf_model, fit_shape, finder=None, aperture_radius=4, xy_bounds=xy_bounds) phot = psfphot(data, error=error, init_params=init_params) assert phot['x_fit'] < 64.0 assert_allclose(phot['y_fit'], 50.0) # at lower bound xy_bounds = (1, None) psfphot = PSFPhotometry(psf_model, fit_shape, finder=None, aperture_radius=4, xy_bounds=xy_bounds) 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)) 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() localbkg_estimator = LocalBackground(5, 10, bkgstat) finder = DAOStarFinder(10.0, 2.0) init_params = QTable() init_params['x'] = [54, 29, 80] init_params['y'] = [8, 26, 29] psfphot = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, mode='new', localbkg_estimator=localbkg_estimator, aperture_radius=4) phot = psfphot(data, error=error, init_params=init_params) cols = ['id', 'group_id', 'group_size', 'iter_detected', 'local_bkg'] assert phot.colnames[:5] == cols assert len(psfphot.fit_results) == 2 assert 'iter_detected' in phot.colnames assert len(phot) == len(sources) resid_data = psfphot.make_residual_image(data, 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', localbkg_estimator=localbkg_estimator, aperture_radius=4) phot2 = psfphot(data << unit, error=error << unit, init_params=init_params) assert phot2['flux_fit'].unit == unit colnames = ('flux_init', 'flux_fit', 'flux_err', 'local_bkg') for col in colnames: assert phot2[col].unit == unit assert_allclose(phot2[col].value, phot[col]) # test NDData with units uncertainty = StdDevUncertainty(error << unit) nddata = NDData(data << unit, uncertainty=uncertainty) phot3 = psfphot(nddata, init_params=init_params) colnames = ('flux_init', 'flux_fit', 'flux_err', 'local_bkg') for col in colnames: assert phot3[col].unit == unit assert_allclose(phot3[col].value, phot2[col].value) resid_nddata = psfphot.make_residual_image(nddata, 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', localbkg_estimator=localbkg_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, 6.0) sub_shape = psf_shape grouper = SourceGrouper(10) psfphot = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, grouper=grouper, aperture_radius=4, sub_shape=sub_shape, mode='all', maxiters=3) phot = psfphot(data) cols = ['id', 'group_id', 'group_size', 'iter_detected', 'local_bkg'] assert phot.colnames[:5] == cols assert len(phot) == 7 assert_equal(phot['group_id'], [1, 2, 3, 1, 2, 2, 3]) assert_equal(phot['iter_detected'], [1, 1, 1, 2, 2, 2, 2]) assert_allclose(phot['flux_fit'], [1000, 1000, 1000, 100, 50, 100, 100]) resid = psfphot.make_residual_image(data, 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, 6.0) psfphotu = IterativePSFPhotometry(psf_model, fit_shape, finder=finderu, grouper=grouper, aperture_radius=4, sub_shape=sub_shape, mode='all', maxiters=3) phot2 = psfphotu(data << unit) assert len(phot2) == 7 assert_equal(phot2['group_id'], [1, 2, 3, 1, 2, 2, 3]) assert_equal(phot2['iter_detected'], [1, 1, 1, 2, 2, 2, 2]) colnames = ('flux_init', 'flux_fit', 'flux_err', 'local_bkg') for col in colnames: assert phot2[col].unit == unit assert_allclose(phot2[col].value, phot[col]) # test NDData with units nddata = NDData(data * unit) phot3 = psfphotu(nddata) colnames = ('flux_init', 'flux_fit', 'flux_err', 'local_bkg') for col in colnames: assert phot3[col].unit == unit assert_allclose(phot3[col].value, phot[col]) resid_nddata = psfphotu.make_residual_image(nddata, psf_shape=fit_shape) assert isinstance(resid_nddata, NDData) assert resid_nddata.unit == unit 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) grouper = SourceGrouper(min_separation=1.3 * fwhm) fitter = TRFLSQFitter() fit_shape = (5, 5) sub_shape = fit_shape psfphot = IterativePSFPhotometry(psf_model, fit_shape=fit_shape, finder=daofinder, mode='all', grouper=grouper, maxiters=2, sub_shape=sub_shape, aperture_radius=3, fitter=fitter) match = r'One or more .* may not have converged' with pytest.warns(AstropyUserWarning, match=match): phot = psfphot(data, error=error) assert len(phot) == 38 def test_iterative_psf_photometry_subshape(): """ A ValueError should not be raised if sub_shape=None and the model does not have a bounding box. """ fwhm = 3.5 psf_model = CircularGaussianPRF(flux=1, fwhm=fwhm) data, _ = make_psf_model_image((150, 150), psf_model, n_sources=30, model_shape=(11, 11), flux=(50, 100), min_separation=1, seed=0) daofinder = DAOStarFinder(threshold=0.5, fwhm=fwhm) grouper = SourceGrouper(min_separation=1.3 * fwhm) fitter = TRFLSQFitter() fit_shape = (5, 5) sub_shape = None psf_model.bounding_box = None psfphot = IterativePSFPhotometry(psf_model, fit_shape=fit_shape, finder=daofinder, mode='all', grouper=grouper, maxiters=2, sub_shape=sub_shape, aperture_radius=3, fitter=fitter) match = r'model_shape must be specified .* does not have a bounding_box' with pytest.raises(ValueError, match=match): psfphot(data) def test_iterative_psf_photometry_inputs(): psf_model = CircularGaussianPRF(flux=1, fwhm=2.7) fit_shape = (5, 5) finder = DAOStarFinder(10.0, 2.0) match = 'finder cannot be None for IterativePSFPhotometry' with pytest.raises(ValueError, match=match): _ = IterativePSFPhotometry(psf_model, fit_shape, finder=None, aperture_radius=4) match = 'aperture_radius cannot be None for IterativePSFPhotometry' with pytest.raises(ValueError, match=match): _ = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=None) match = 'maxiters must be a strictly-positive scalar' with pytest.raises(ValueError, match=match): _ = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, maxiters=-1) with pytest.raises(ValueError, match=match): _ = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, maxiters=[1, 2]) match = 'maxiters must be an integer' with pytest.raises(ValueError, match=match): _ = IterativePSFPhotometry(psf_model, fit_shape, finder=finder, aperture_radius=4, maxiters=3.14) 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_out_of_bounds_centroids(): sources = Table() sources['id'] = np.arange(8) + 1 sources['flux'] = 1 sources['x_0'] = [-1.4, 34.5, 14.2, -0.7, 34.5, 14.2, 51.3, 52.0] sources['y_0'] = [13, -0.2, -1.6, 40, 51.1, 50.9, 12.2, 42.3] sources['sigma'] = 3.1 shape = (51, 51) psf_model = CircularGaussianPRF(flux=1, fwhm=3.1) data = make_model_image(shape, psf_model, sources) fit_shape = (11, 11) psfphot = PSFPhotometry(psf_model, fit_shape, aperture_radius=10) phot = psfphot(data, init_params=sources) # at least one of the best-fit centroids should be # out of the bounds of the dataset, producing a # masked value in the `cfit` column: assert np.any(np.isnan(phot['cfit'])) def test_make_psf_model(): normalize = False sigma = 3.0 amplitude = 1.0 / (2 * np.pi * sigma**2) xcen = ycen = 0.0 psf0 = Gaussian2D(amplitude, xcen, ycen, sigma, sigma) psf1 = make_psf_model(psf0, x_name='x_mean', y_name='y_mean', normalize=normalize) psf2 = make_psf_model(psf0, normalize=normalize) psf3 = make_psf_model(psf0, x_name='x_mean', normalize=normalize) psf4 = make_psf_model(psf0, y_name='y_mean', normalize=normalize) yy, xx = np.mgrid[0:101, 0:101] psf = psf1.copy() xval = 48 yval = 52 flux = 14.51 psf.x_mean_2 = xval psf.y_mean_2 = yval data = psf(xx, yy) * flux fit_shape = 7 init_params = Table([[46.1], [57.3], [7.1]], names=['x_0', 'y_0', 'flux_0']) phot1 = PSFPhotometry(psf1, fit_shape, aperture_radius=None) tbl1 = phot1(data, init_params=init_params) phot2 = PSFPhotometry(psf2, fit_shape, aperture_radius=None) tbl2 = phot2(data, init_params=init_params) phot3 = PSFPhotometry(psf3, fit_shape, aperture_radius=None) tbl3 = phot3(data, init_params=init_params) phot4 = PSFPhotometry(psf4, fit_shape, aperture_radius=None) tbl4 = phot4(data, init_params=init_params) assert_allclose((tbl1['x_fit'][0], tbl1['y_fit'][0], tbl1['flux_fit'][0]), (xval, yval, flux)) assert_allclose((tbl2['x_fit'][0], tbl2['y_fit'][0], tbl2['flux_fit'][0]), (xval, yval, flux)) assert_allclose((tbl3['x_fit'][0], tbl3['y_fit'][0], tbl3['flux_fit'][0]), (xval, yval, flux)) assert_allclose((tbl4['x_fit'][0], tbl4['y_fit'][0], tbl4['flux_fit'][0]), (xval, yval, flux)) def test_move_column(): 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) tbl = QTable() tbl['a'] = [1, 2, 3] tbl['b'] = [4, 5, 6] tbl['c'] = [7, 8, 9] tbl1 = psfphot._move_column(tbl, 'a', 'c') assert tbl1.colnames == ['b', 'c', 'a'] tbl2 = psfphot._move_column(tbl, 'd', 'b') assert tbl2.colnames == ['a', 'b', 'c'] tbl3 = psfphot._move_column(tbl, 'b', 'b') assert tbl3.colnames == ['a', 'b', 'c'] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/tests/test_simulation.py0000644000175100001660000000537214755160622022752 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the simulation module. """ import numpy as np import pytest from astropy.modeling.models import Gaussian2D from astropy.table import Table from numpy.testing import assert_equal from photutils.psf import (CircularGaussianPRF, make_psf_model, make_psf_model_image) def test_make_psf_model_image(): shape = (401, 451) n_sources = 100 model = CircularGaussianPRF(fwhm=2.7) data, params = make_psf_model_image(shape, model, n_sources) assert data.shape == shape assert isinstance(params, Table) assert len(params) == n_sources model_shape = (13, 13) data2, params2 = make_psf_model_image(shape, model, n_sources, model_shape=model_shape) assert_equal(data, data2) assert len(params2) == n_sources flux = (100, 200) fwhm = (2.5, 4.5) alpha = (0, 1) n_sources = 10 data, params = make_psf_model_image(shape, model, n_sources, seed=0, flux=flux, fwhm=fwhm, alpha=alpha) assert len(params) == n_sources colnames = ('id', 'x_0', 'y_0', 'flux', 'fwhm') for colname in colnames: assert colname in params.colnames assert 'alpha' not in params.colnames assert np.min(params['flux']) >= flux[0] assert np.max(params['flux']) <= flux[1] assert np.min(params['fwhm']) >= fwhm[0] assert np.max(params['fwhm']) <= fwhm[1] def test_make_psf_model_image_custom(): shape = (401, 451) n_sources = 100 model = Gaussian2D() psf_model = make_psf_model(model, x_name='x_mean', y_name='y_mean') data, params = make_psf_model_image(shape, psf_model, n_sources, model_shape=(11, 11)) assert data.shape == shape assert isinstance(params, Table) assert len(params) == n_sources def test_make_psf_model_image_inputs(): shape = (50, 50) match = 'psf_model must be an Astropy Model subclass' with pytest.raises(TypeError, match=match): make_psf_model_image(shape, None, 2) match = 'psf_model must be two-dimensional' model = CircularGaussianPRF(fwhm=2.7) model.n_inputs = 3 with pytest.raises(ValueError, match=match): make_psf_model_image(shape, model, 2) match = 'model_shape must be specified if the model does not have' model = CircularGaussianPRF(fwhm=2.7) model.bounding_box = None with pytest.raises(ValueError, match=match): make_psf_model_image(shape, model, 2) match = 'Invalid PSF model - could not find PSF parameter names' model = Gaussian2D() with pytest.raises(ValueError, match=match): make_psf_model_image(shape, model, 2) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/tests/test_utils.py0000644000175100001660000001261714755160622021726 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the utils module. """ import astropy.units as u import numpy as np import pytest from astropy.modeling.models import Gaussian1D, Gaussian2D from astropy.table import QTable from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose from photutils.psf import CircularGaussianPRF, make_psf_model_image from photutils.psf.utils import (_get_psf_model_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) 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) @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']) if with_units: for column in fit_tbl.colnames: if 'flux' in column: assert fit_tbl['flux_fit'].unit == unit 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) fwhm = fit_fwhm(data, fwhm=3) assert isinstance(fwhm, np.ndarray) assert len(fwhm) == 1 assert_allclose(fwhm, fwhm0) # test warning message match = 'may not have converged. Please carefully check your results' with pytest.warns(AstropyUserWarning, match=match): fwhm = fit_fwhm(np.zeros(data.shape) + 1) assert len(fwhm) == 1 @pytest.mark.parametrize('with_units', [False, True]) def test_fit_fwhm_multiple(test_data, with_units): data, sources = test_data unit = u.nJy if with_units: data = data * unit xypos = list(zip(sources['x_0'], sources['y_0'], strict=True)) fwhms = fit_fwhm(data, xypos=xypos, fit_shape=(5, 5)) assert isinstance(fwhms, np.ndarray) assert len(fwhms) == len(sources) assert_allclose(fwhms, sources['fwhm']) def test_interpolate_missing_data(): data = np.arange(100).reshape(10, 10) mask = np.zeros_like(data, dtype=bool) mask[5, 5] = True data_int = _interpolate_missing_data(data, mask, method='nearest') assert 54 <= data_int[5, 5] <= 56 data_int = _interpolate_missing_data(data, mask, method='cubic') assert 54 <= data_int[5, 5] <= 56 match = "'data' must be a 2D array." with pytest.raises(ValueError, match=match): _interpolate_missing_data(np.arange(10), mask) match = "'mask' and 'data' must have the same shape." with pytest.raises(ValueError, match=match): _interpolate_missing_data(data, mask[1:, :]) match = 'Unsupported interpolation method' with pytest.raises(ValueError, match=match): _interpolate_missing_data(data, mask, method='invalid') def test_validate_psf_model(): model = np.arange(10) match = 'psf_model must be an Astropy Model subclass' with pytest.raises(TypeError, match=match): _validate_psf_model(model) match = 'psf_model must be two-dimensional' model = Gaussian1D() with pytest.raises(ValueError, match=match): _validate_psf_model(model) match = 'psf_model must be two-dimensional' model = Gaussian1D() with pytest.raises(ValueError, match=match): _validate_psf_model(model) def test_get_psf_model_params(): model = CircularGaussianPRF(fwhm=1.0) params = _get_psf_model_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_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_params(model) assert len(params) == 3 assert params == set_params ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/psf/utils.py0000644000175100001660000003501214755160622017517 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides utilities for PSF-fitting photometry. """ import warnings import numpy as np from astropy.modeling import Model from astropy.table import QTable from astropy.units import Quantity from astropy.utils.exceptions import AstropyUserWarning from scipy import interpolate from photutils.centroids import centroid_com from photutils.psf.functional_models import CircularGaussianPRF from photutils.utils import CutoutImage from photutils.utils._parameters import as_pair __all__ = ['fit_2dgaussian', 'fit_fwhm'] def fit_2dgaussian(data, *, xypos=None, fwhm=None, fix_fwhm=True, fit_shape=None, mask=None, error=None): """ Fit a 2D Gaussian model to one or more sources in an image. This convenience function uses a `~photutils.psf.CircularGaussianPRF` model to fit the sources using the `~photutils.psf.PSFPhotometry` class. Non-finite values (e.g., NaN or inf) in the ``data`` array are automatically masked. Parameters ---------- data : 2D array The 2D array of the image. The input array must be background subtracted. xypos : array-like, optional The initial (x, y) pixel coordinates of the sources. If `None`, then one source will be fit with an initial position using the center-of-mass centroid of the ``data`` array. fwhm : float, optional The initial guess for the FWHM of the Gaussian PSF model. If `None`, then the initial guess is half the mean of the x and y sizes of the ``fit_shape`` values. fix_fwhm : bool, optional Whether to fix the FWHM of the Gaussian PSF model during the fitting process. fit_shape : int or tuple of two ints, optional The shape of the fitting region. If a scalar, then it is assumed to be a square. If `None`, then the shape of the input ``data`` will be used. mask : array-like (bool), optional A boolean mask with the same shape as the input ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. error : 2D array, optional The pixel-wise Gaussian 1-sigma errors of the input ``data``. ``error`` is assumed to include *all* sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. If a `~astropy.units.Quantity` array, then ``data`` must also be a `~astropy.units.Quantity` array with the same units. Returns ------- result : `~photutils.psf.PSFPhotometry` The PSF-fitting photometry results. See Also -------- fit_fwhm : Fit the FWHM of one or more sources in an image. Notes ----- The source(s) are fit with a `~photutils.psf.CircularGaussianPRF` model using the `~photutils.psf.PSFPhotometry` class. The initial guess for the flux is the sum of the pixel values within the fitting region. If ``fwhm`` is `None`, then the initial guess for the FWHM is half the mean of the x and y sizes of the ``fit_shape`` values. Examples -------- Fit a 2D Gaussian model to a 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) >>> phot_tbl = fit.results # doctest: +FLOAT_CMP >>> cols = ['x_fit', 'y_fit', 'fwhm_fit', 'flux_fit'] >>> for col in cols: ... phot_tbl[col].info.format = '.4f' # optional format >>> print(phot_tbl[['id'] + cols]) id x_fit y_fit fwhm_fit flux_fit --- ------- ------- -------- -------- 1 22.1700 28.8700 3.1230 9.7000 Fit a 2D Gaussian model to multiple sources in an image: >>> import numpy as np >>> from photutils.detection import DAOStarFinder >>> from photutils.psf import (CircularGaussianPRF, fit_2dgaussian, ... make_psf_model_image) >>> model = CircularGaussianPRF() >>> data, sources = make_psf_model_image((100, 100), model, 5, ... min_separation=25, ... model_shape=(15, 15), ... flux=(100, 200), fwhm=[3, 8]) >>> finder = DAOStarFinder(0.1, 5) >>> finder_tbl = finder(data) >>> xypos = list(zip(sources['x_0'], sources['y_0'])) >>> psfphot = fit_2dgaussian(data, xypos=xypos, fit_shape=7, ... fix_fwhm=False) >>> phot_tbl = psfphot.results >>> len(phot_tbl) 5 Here we show only a few columns of the photometry table: >>> cols = ['x_fit', 'y_fit', 'fwhm_fit', 'flux_fit'] >>> for col in cols: ... phot_tbl[col].info.format = '.4f' # optional format >>> print(phot_tbl[['id'] + cols]) id x_fit y_fit fwhm_fit flux_fit --- ------- ------- -------- -------- 1 61.7787 74.6905 5.6947 147.9988 2 30.2017 27.5858 5.2138 123.2373 3 10.5237 82.3776 7.6551 180.1881 4 8.4214 12.0369 3.2026 192.3530 5 76.9412 35.9061 6.6600 126.6130 """ # prevent circular import from photutils.psf.photometry import PSFPhotometry if xypos is None: xypos = centroid_com(data) xypos = np.atleast_2d(xypos) if fit_shape is None: fit_shape = data.shape else: fit_shape = as_pair('fit_shape', fit_shape, lower_bound=(1, 0), check_odd=True) flux_init = [] for yxpos in xypos[:, ::-1]: cutout = CutoutImage(data, yxpos, tuple(fit_shape)) flux_init.append(np.sum(cutout.data)) if isinstance(data, Quantity): flux_init <<= data.unit init_params = QTable() init_params['x'] = xypos[:, 0] init_params['y'] = xypos[:, 1] init_params['flux'] = flux_init if fwhm is None: fwhm = np.mean(fit_shape) / 2.0 init_params['fwhm'] = fwhm model = CircularGaussianPRF(fwhm=fwhm) model.fwhm.min = 0.0 if not fix_fwhm: model.fwhm.fixed = False phot = PSFPhotometry(model, fit_shape) _ = phot(data, mask=mask, error=error, init_params=init_params) return phot def fit_fwhm(data, *, xypos=None, fwhm=None, fit_shape=None, mask=None, error=None): """ Fit the FWHM of one or more sources in an image. This convenience function uses a `~photutils.psf.CircularGaussianPRF` model to fit the sources using the `~photutils.psf.PSFPhotometry` class. Non-finite values (e.g., NaN or inf) in the ``data`` array are automatically masked. Parameters ---------- data : 2D array The 2D array of the image. The input array must be background subtracted. xypos : array-like, optional The initial (x, y) pixel coordinates of the sources. If `None`, then one source will be fit with an initial position using the center-of-mass centroid of the ``data`` array. fwhm : float, optional The initial guess for the FWHM of the Gaussian PSF model. If `None`, then the initial guess is half the mean of the x and y sizes of the ``fit_shape`` values. fit_shape : int or tuple of two ints, optional The shape of the fitting region. If a scalar, then it is assumed to be a square. If `None`, then the shape of the input ``data`` will be used. mask : array-like (bool), optional A boolean mask with the same shape as the input ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. error : 2D array, optional The pixel-wise Gaussian 1-sigma errors of the input ``data``. ``error`` is assumed to include *all* sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. If a `~astropy.units.Quantity` array, then ``data`` must also be a `~astropy.units.Quantity` array with the same units. Returns ------- fwhm : `~numpy.ndarray` The FWHM of the sources. Note that the returned FWHM values are always positive. See Also -------- fit_2dgaussian : Fit a 2D Gaussian model to one or more sources in an image. Notes ----- The source(s) are fit using the :func:`fit_2dgaussian` function, which uses a `~photutils.psf.CircularGaussianPRF` model with the `~photutils.psf.PSFPhotometry` class. The initial guess for the flux is the sum of the pixel values within the fitting region. If ``fwhm`` is `None`, then the initial guess for the FWHM is half the mean of the x and y sizes of the ``fit_shape`` values. Examples -------- Fit the FWHM of a single source (e.g., a cutout image): >>> import numpy as np >>> from photutils.psf import CircularGaussianPRF, fit_fwhm >>> yy, xx = np.mgrid[:51, :51] >>> model = CircularGaussianPRF(x_0=22.17, y_0=28.87, fwhm=3.123, flux=9.7) >>> data = model(xx, yy) >>> fwhm = fit_fwhm(data) >>> fwhm # doctest: +FLOAT_CMP array([3.123]) Fit the FWHMs of multiple sources in an image: >>> import numpy as np >>> from photutils.detection import DAOStarFinder >>> from photutils.psf import (CircularGaussianPRF, fit_fwhm, ... make_psf_model_image) >>> model = CircularGaussianPRF() >>> data, sources = make_psf_model_image((100, 100), model, 5, ... min_separation=25, ... model_shape=(15, 15), ... flux=(100, 200), fwhm=[3, 8]) >>> finder = DAOStarFinder(0.1, 5) >>> finder_tbl = finder(data) >>> xypos = list(zip(sources['x_0'], sources['y_0'])) >>> fwhms = fit_fwhm(data, xypos=xypos, fit_shape=7) >>> fwhms # doctest: +FLOAT_CMP array([5.69467204, 5.21376414, 7.65508658, 3.20255356, 6.66003098]) """ with warnings.catch_warnings(record=True) as fit_warnings: phot = fit_2dgaussian(data, xypos=xypos, fwhm=fwhm, fix_fwhm=False, fit_shape=fit_shape, mask=mask, error=error) if len(fit_warnings) > 0: warnings.warn('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.', AstropyUserWarning) return np.array(phot.results['fwhm_fit']) def _interpolate_missing_data(data, mask, method='cubic'): """ Interpolate missing data as identified by the ``mask`` keyword. Parameters ---------- data : 2D `~numpy.ndarray` An array containing the 2D image. mask : 2D bool `~numpy.ndarray` A 2D boolean mask array with the same shape as the input ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. The masked data points are those that will be interpolated. method : {'cubic', 'nearest'}, optional The method of used to interpolate the missing data: * ``'cubic'``: Masked data are interpolated using 2D cubic splines. This is the default. * ``'nearest'``: Masked data are interpolated using nearest-neighbor interpolation. Returns ------- data_interp : 2D `~numpy.ndarray` The interpolated 2D image. """ data_interp = np.copy(data) if len(data_interp.shape) != 2: raise ValueError("'data' must be a 2D array.") if mask.shape != data.shape: raise ValueError("'mask' and 'data' must have the same shape.") # initialize the interpolator y, x = np.indices(data_interp.shape) xy = np.dstack((x[~mask].ravel(), y[~mask].ravel()))[0] z = data_interp[~mask].ravel() # interpolate the missing data if method == 'nearest': interpol = interpolate.NearestNDInterpolator(xy, z) elif method == 'cubic': interpol = interpolate.CloughTocher2DInterpolator(xy, z) else: raise ValueError('Unsupported interpolation method.') xy_missing = np.dstack((x[mask].ravel(), y[mask].ravel()))[0] data_interp[mask] = interpol(xy_missing) return data_interp def _validate_psf_model(psf_model): """ Validate the PSF model. The PSF model must be a subclass of `astropy .modeling.Fittable2DModel`. It must also be two-dimensional and have a single output. Parameters ---------- psf_model : `astropy.modeling.Fittable2DModel` The PSF model to validate. Returns ------- psf_model : `astropy.modeling.Model` The validated PSF model. Raises ------ TypeError If the PSF model is not an Astropy Model subclass. ValueError If the PSF model is not two-dimensional with n_inputs=2 and n_outputs=1. """ if not isinstance(psf_model, Model): raise TypeError('psf_model must be an Astropy Model subclass.') if psf_model.n_inputs != 2 or psf_model.n_outputs != 1: raise ValueError('psf_model must be two-dimensional with ' 'n_inputs=2 and n_outputs=1.') return psf_model def _get_psf_model_params(psf_model): """ Get the names of the PSF model parameters corresponding to x, y, and flux. The PSF model must have parameters called 'x_0', 'y_0', and 'flux' or it must have 'x_name', 'y_name', and 'flux_name' attributes (i.e., output from `make_psf_model`). Otherwise, a `ValueError` is raised. The PSF model must be a subclass of `astropy.modeling.Model`. It must also be two-dimensional and have a single output. Parameters ---------- psf_model : `astropy.modeling.Model` The PSF model to validate. Returns ------- model_params : tuple A tuple of the PSF model parameter names. """ psf_model = _validate_psf_model(psf_model) params1 = ('x_0', 'y_0', 'flux') params2 = ('x_name', 'y_name', 'flux_name') if all(name in psf_model.param_names for name in params1): model_params = params1 elif all(params := [getattr(psf_model, name, None) for name in params2]): model_params = tuple(params) else: msg = 'Invalid PSF model - could not find PSF parameter names.' raise ValueError(msg) return model_params ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1739907483.718927 photutils-2.2.0/photutils/segmentation/0000755000175100001660000000000014755160634017714 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/segmentation/__init__.py0000644000175100001660000000073314755160622022025 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage contains tools for detecting sources using image segmentation and measuring their centroids, photometry, and morphological properties. """ from .catalog import * # noqa: F401, F403 from .core import * # noqa: F401, F403 from .deblend import * # noqa: F401, F403 from .detect import * # noqa: F401, F403 from .finder import * # noqa: F401, F403 from .utils import * # noqa: F401, F403 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/segmentation/catalog.py0000644000175100001660000040740114755160622021703 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for calculating the properties of sources defined by a segmentation image. """ import functools import inspect import warnings from copy import deepcopy import astropy.units as u import numpy as np from astropy.stats import SigmaClip, gaussian_fwhm_to_sigma from astropy.table import QTable from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from scipy.ndimage import binary_erosion, convolve, map_coordinates from scipy.optimize import root_scalar from photutils.aperture import (BoundingBox, CircularAperture, EllipticalAperture, RectangularAnnulus) from photutils.background import SExtractorBackground from photutils.centroids import centroid_quadratic from photutils.morphology import gini as gini_func from photutils.segmentation.core import SegmentationImage from photutils.utils._misc import _get_meta from photutils.utils._moments import _moments, _moments_central from photutils.utils._progress_bars import add_progress_bar from photutils.utils._quantity_helpers import process_quantities from photutils.utils.cutouts import CutoutImage __all__ = ['SourceCatalog'] # default table columns for `to_table()` output DEFAULT_COLUMNS = ['label', 'xcentroid', 'ycentroid', 'sky_centroid', 'bbox_xmin', 'bbox_xmax', 'bbox_ymin', 'bbox_ymax', 'area', 'semimajor_sigma', 'semiminor_sigma', 'orientation', 'eccentricity', 'min_value', 'max_value', 'local_background', 'segment_flux', 'segment_fluxerr', 'kron_flux', 'kron_fluxerr'] def as_scalar(method): """ Return a decorated method where it will always return a scalar value (instead of a length-1 tuple/list/array) if the class is scalar. Note that lazyproperties that begin with '_' should not have this decorator applied. Such properties are assumed to always be iterable and when slicing (see __getitem__) from a cached multi-object catalog to create a single-object catalog, they will no longer be scalar. Parameters ---------- method : function The method to be decorated. Returns ------- decorator : function The decorated method. """ @functools.wraps(method) def _as_scalar(*args, **kwargs): result = method(*args, **kwargs) try: return (result[0] if args[0].isscalar and len(result) == 1 else result) except TypeError: # if result has no len return result return _as_scalar def use_detcat(method): """ Return a decorated method where it will return the value from the detection image catalog instead of using the method to calculate it. Parameters ---------- method : function The method to be decorated. Returns ------- decorator : function The decorated method. """ @functools.wraps(method) def _use_detcat(self, *args, **kwargs): if self._detection_cat is None: return method(self, *args, **kwargs) return getattr(self._detection_cat, method.__name__) return _use_detcat class SourceCatalog: """ Class to create a catalog of photometry and morphological properties for sources defined by a segmentation image. Parameters ---------- data : 2D `~numpy.ndarray` or `~astropy.units.Quantity`, optional The 2D array from which to calculate the source photometry and properties. If ``convolved_data`` is input, then a convolved version of ``data`` will be used instead of ``data`` to calculate the source centroid and morphological properties. Source photometry is always measured from ``data``. For accurate source properties and photometry, ``data`` should be background-subtracted. Non-finite ``data`` values (NaN and inf) are automatically masked. segment_img : `~photutils.segmentation.SegmentationImage` A `~photutils.segmentation.SegmentationImage` object defining the sources. convolved_data : 2D `~numpy.ndarray` or `~astropy.units.Quantity`, optional The 2D array used to calculate the source centroid and morphological properties. Typically, ``convolved_data`` should be the input ``data`` array convolved by the same smoothing kernel that was applied to the detection image when deriving the source segments (e.g., see :func:`~photutils.segmentation.detect_sources`). If ``convolved_data`` is `None`, then the unconvolved ``data`` will be used instead. Non-finite ``convolved_data`` values (NaN and inf) are not automatically masked, unless they are at the same position of non-finite values in the input ``data`` array. Such pixels can be masked using the ``mask`` keyword. error : 2D `~numpy.ndarray` or `~astropy.units.Quantity`, optional The total error array corresponding to the input ``data`` array. ``error`` is assumed to include *all* sources of error, including the Poisson error of the sources (see `~photutils.utils.calc_total_error`) . ``error`` must have the same shape as the input ``data``. If ``data`` is a `~astropy.units.Quantity` array then ``error`` must be a `~astropy.units.Quantity` array (and vice versa) with identical units. Non-finite ``error`` values (NaN and inf) are not automatically masked, unless they are at the same position of non-finite values in the input ``data`` array. Such pixels can be masked using the ``mask`` keyword. See the Notes section below for details on the error propagation. mask : 2D `~numpy.ndarray` (bool), optional A boolean mask with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. Masked data are excluded from all calculations. Non-finite values (NaN and inf) in the input ``data`` are automatically masked. background : float, 2D `~numpy.ndarray`, or `~astropy.units.Quantity`, \ optional The background level that was *previously* present in the input ``data``. ``background`` may either be a scalar value or a 2D image with the same shape as the input ``data``. If ``data`` is a `~astropy.units.Quantity` array then ``background`` must be a `~astropy.units.Quantity` array (and vice versa) with identical units. Inputing the ``background`` merely allows for its properties to be measured within each source segment. The input ``background`` does *not* get subtracted from the input ``data``, which should already be background-subtracted. Non-finite ``background`` values (NaN and inf) are not automatically masked, unless they are at the same position of non-finite values in the input ``data`` array. Such pixels can be masked using the ``mask`` keyword. wcs : WCS object or `None`, optional A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). If `None`, then all sky-based properties will be set to `None`. This keyword will be ignored if ``detection_cat`` is input. localbkg_width : int, optional The width of the rectangular annulus used to compute a local background around each source. If zero, then no local background subtraction is performed. The local background affects the ``min_value``, ``max_value``, ``segment_flux``, ``kron_flux``, and ``fluxfrac_radius`` properties. It is also used when calculating circular and Kron aperture photometry (i.e., `circular_photometry` and `kron_photometry`). It does not affect the moment-based morphological properties of the source. apermask_method : {'correct', 'mask', 'none'}, optional The method used to handle neighboring sources when performing aperture photometry (e.g., circular apertures or elliptical Kron apertures). This parameter also affects the Kron radius. The options are: * 'correct': replace pixels assigned to neighboring sources by replacing them with pixels on the opposite side of the source center (equivalent to MASK_TYPE=CORRECT in SourceExtractor). * 'mask': mask pixels assigned to neighboring sources (equivalent to MASK_TYPE=BLANK in SourceExtractor). * 'none': do not mask any pixels (equivalent to MASK_TYPE=NONE in SourceExtractor). This keyword will be ignored if ``detection_cat`` is input. kron_params : tuple of 2 or 3 floats, optional A list of parameters used to determine the Kron aperture. The first item is the scaling parameter of the unscaled Kron radius and the second item represents the minimum value for the unscaled Kron radius in pixels. The optional third item is the minimum circular radius in pixels. If ``kron_params[0]`` * `kron_radius` * sqrt(`semimajor_sigma` * `semiminor_sigma`) is less than or equal to this radius, then the Kron aperture will be a circle with this minimum radius. This keyword will be ignored if ``detection_cat`` is input. detection_cat : `SourceCatalog`, optional A `SourceCatalog` object for the detection image. The segmentation image used to create the detection catalog must be the same one input to ``segment_img``. If input, then the detection catalog source centroids and morphological/shape properties will be returned instead of calculating them from the input ``data``. The detection catalog centroids and shape properties will also be used to perform aperture photometry (i.e., circular and Kron). If ``detection_cat`` is input, then the input ``wcs``, ``apermask_method``, and ``kron_params`` keywords will be ignored. This keyword affects `circular_photometry` (including returned apertures), all Kron parameters (Kron radius, flux, flux errors, apertures, and custom `kron_photometry`), and `fluxfrac_radius` (which is based on the Kron flux). progress_bar : bool, optional Whether to display a progress bar when calculating some properties (e.g., ``kron_radius``, ``kron_flux``, ``fluxfrac_radius``, ``circular_photometry``, ``centroid_win``, ``centroid_quad``). The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. Notes ----- ``data`` should be background-subtracted for accurate source photometry and properties. The previously-subtracted background can be passed into this class to calculate properties of the background for each source. Note that this class does not convert input data in surface-brightness units to flux or counts. Conversion from surface-brightness units should be performed before using this class. function returns the sum of the (weighted) input ``data`` values within the aperture. It does not convert data in surface brightness units to flux or counts. `SourceExtractor`_'s centroid and morphological parameters are always calculated from a convolved, or filtered, "detection" image (``convolved_data``), i.e., the image used to define the segmentation image. The usual downside of the filtering is the sources will be made more circular than they actually are. If you wish to reproduce `SourceExtractor`_ centroid and morphology results, then input the ``convolved_data`` If ``convolved_data`` and ``kernel`` are both `None`, then the unfiltered ``data`` will be used for the source centroid and morphological parameters. Negative data values within the source segment are set to zero when calculating morphological properties based on image moments. Negative values could occur, for example, if the segmentation image was defined from a different image (e.g., different bandpass) or if the background was oversubtracted. However, `~photutils.segmentation.SourceCatalog.segment_flux` always includes the contribution of negative ``data`` values. The input ``error`` array is assumed to include *all* sources of error, including the Poisson error of the sources. `~photutils.segmentation.SourceCatalog.segment_fluxerr` is simply the quadrature sum of the pixel-wise total errors over the unmasked pixels within the source segment: .. math:: \\Delta F = \\sqrt{\\sum_{i \\in S} \\sigma_{\\mathrm{tot}, i}^2} where :math:`\\Delta F` is `~photutils.segmentation.SourceCatalog.segment_fluxerr`, :math:`S` are the unmasked pixels in the source segment, and :math:`\\sigma_{\\mathrm{tot}, i}` is the input ``error`` array. Custom errors for source segments can be calculated using the `~photutils.segmentation.SourceCatalog.error_ma` and `~photutils.segmentation.SourceCatalog.background_ma` properties, which are 2D `~numpy.ma.MaskedArray` cutout versions of the input ``error`` and ``background`` arrays. The mask is `True` for pixels outside of the source segment, masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ """ def __init__(self, data, segment_img, *, convolved_data=None, error=None, mask=None, background=None, wcs=None, localbkg_width=0, apermask_method='correct', kron_params=(2.5, 1.4, 0.0), detection_cat=None, progress_bar=False): inputs = (data, convolved_data, error, background) names = ('data', 'convolved_data', 'error', 'background') inputs, unit = process_quantities(inputs, names) (data, convolved_data, error, background) = inputs self._data_unit = unit self._data = self._validate_array(data, 'data', shape=False) self._convolved_data = self._validate_array(convolved_data, 'convolved_data') self._segment_img = self._validate_segment_img(segment_img) self._error = self._validate_array(error, 'error') self._mask = self._validate_array(mask, 'mask') self._background = self._validate_array(background, 'background') self.wcs = wcs self.localbkg_width = self._validate_localbkg_width(localbkg_width) self.apermask_method = self._validate_apermask_method(apermask_method) self.kron_params = self._validate_kron_params(kron_params) self.progress_bar = progress_bar # needed for ordering and isscalar # NOTE: calculate slices before labels for performance. # _labels is initially always a non-scalar array, but # it can become a numpy scalar after indexing/slicing. self._slices = self._segment_img.slices self._labels = self._segment_img.labels if self._labels.shape == (0,): raise ValueError('segment_img must have at least one non-zero ' 'label.') self._detection_cat = self._validate_detection_cat(detection_cat) attrs = ('wcs', 'apermask_method', 'kron_params') if self._detection_cat is not None: for attr in attrs: setattr(self, attr, getattr(self._detection_cat, attr)) if convolved_data is None: self._convolved_data = self._data self._apermask_kwargs = { 'circ': {'method': 'exact'}, 'kron': {'method': 'exact'}, 'fluxfrac': {'method': 'exact'}, 'cen_win': {'method': 'center'} } self.default_columns = DEFAULT_COLUMNS self._extra_properties = [] self.meta = _get_meta() self._update_meta() def _validate_segment_img(self, segment_img): if not isinstance(segment_img, SegmentationImage): raise TypeError('segment_img must be a SegmentationImage') if segment_img.shape != self._data.shape: raise ValueError('segment_img and data must have the same shape.') return segment_img def _validate_array(self, array, name, shape=True): if name == 'mask' and array is np.ma.nomask: array = None if array is not None: # UFuncTypeError is raised when subtracting float # local_background from int data; convert to float array = np.asanyarray(array) if array.ndim != 2: raise ValueError(f'{name} must be a 2D array.') if shape and array.shape != self._data.shape: raise ValueError(f'data and {name} must have the same shape.') return array @staticmethod def _validate_localbkg_width(localbkg_width): if localbkg_width < 0: raise ValueError('localbkg_width must be >= 0') localbkg_width_int = int(localbkg_width) if localbkg_width_int != localbkg_width: raise ValueError('localbkg_width must be an integer') return localbkg_width_int @staticmethod def _validate_apermask_method(apermask_method): if apermask_method not in ('none', 'mask', 'correct'): raise ValueError('Invalid apermask_method value') return apermask_method @staticmethod def _validate_kron_params(kron_params): if np.ndim(kron_params) != 1: raise ValueError('kron_params must be 1D') nparams = len(kron_params) if nparams not in (2, 3): raise ValueError('kron_params must have 2 or 3 elements') if kron_params[0] <= 0: raise ValueError('kron_params[0] must be > 0') if kron_params[1] <= 0: raise ValueError('kron_params[1] must be > 0') if nparams == 3 and kron_params[2] < 0: raise ValueError('kron_params[2] must be >= 0') return tuple(kron_params) def _validate_detection_cat(self, detection_cat): if detection_cat is None: return None if not isinstance(detection_cat, SourceCatalog): raise TypeError('detection_cat must be a SourceCatalog ' 'instance') if not np.array_equal(detection_cat._segment_img, self._segment_img): raise ValueError('detection_cat must have same segment_img as ' 'the input segment_img') return detection_cat def _update_meta(self): attrs = ('localbkg_width', 'apermask_method', 'kron_params') for attr in attrs: self.meta[attr] = getattr(self, attr) def _set_semode(self): # SE emulation self._apermask_kwargs = { 'circ': {'method': 'subpixel', 'subpixels': 5}, 'kron': {'method': 'center'}, 'fluxfrac': {'method': 'subpixel', 'subpixels': 5}, 'cen_win': {'method': 'subpixel', 'subpixels': 11} } @property def _properties(self): """ A list of all class properties, include lazyproperties (even in superclasses). """ def isproperty(obj): return isinstance(obj, property) return [i[0] for i in inspect.getmembers(self.__class__, predicate=isproperty)] @property def properties(self): """ A list of built-in source properties. """ lazyproperties = [name for name in self._lazyproperties if not name.startswith('_')] lazyproperties.remove('isscalar') lazyproperties.remove('nlabels') lazyproperties.extend(['label', 'labels', 'slices']) lazyproperties.sort() return lazyproperties @property def _lazyproperties(self): """ A list of all class lazyproperties (even in superclasses). """ def islazyproperty(obj): return isinstance(obj, lazyproperty) return [i[0] for i in inspect.getmembers(self.__class__, predicate=islazyproperty)] def __getitem__(self, index): if self.isscalar: raise TypeError(f'A scalar {self.__class__.__name__!r} object ' 'cannot be indexed') newcls = object.__new__(self.__class__) # attributes defined in __init__ that are copied directly to the # new class init_attr = ('_data', '_segment_img', '_error', '_mask', '_background', 'wcs', '_data_unit', '_convolved_data', 'localbkg_width', 'apermask_method', 'kron_params', 'default_columns', '_extra_properties', 'meta', '_apermask_kwargs', 'progress_bar') for attr in init_attr: setattr(newcls, attr, getattr(self, attr)) # _labels determines ordering and isscalar attr = '_labels' setattr(newcls, attr, getattr(self, attr)[index]) # need to slice detection_cat, if input attr = '_detection_cat' if getattr(self, attr) is None: setattr(newcls, attr, None) else: setattr(newcls, attr, getattr(self, attr)[index]) attr = '_slices' # Use a numpy object array to allow for fancy and bool indices. # NOTE: None is appended to the list (and then removed) to keep # the array only on the outer level (i.e., prevents recursion). # Otherwise, the tuple of (y, x) slices are not preserved. value = np.array([*getattr(self, attr), None], dtype=object)[:-1][index] if not newcls.isscalar: value = value.tolist() setattr(newcls, attr, value) # evaluated lazyproperty objects and extra properties keys = (set(self.__dict__.keys()) & (set(self._lazyproperties) | set(self._extra_properties))) for key in keys: value = self.__dict__[key] # do not insert attributes that are always scalar (e.g., # isscalar, nlabels), i.e., not an array/list for each # source if np.isscalar(value): continue try: # keep _ lazyproperties as length-1 iterables; # _ lazyproperties should not have @as_scalar applied if newcls.isscalar and key.startswith('_'): if isinstance(value, np.ndarray): val = value[:, np.newaxis][index] else: val = [value[index]] else: val = value[index] except TypeError: # apply fancy indices (e.g., array/list or bool # mask) to lists val = (np.array([*value, None], dtype=object)[:-1][index]).tolist() newcls.__dict__[key] = val return newcls def __str__(self): cls_name = f'<{self.__class__.__module__}.{self.__class__.__name__}>' with np.printoptions(threshold=25, edgeitems=5): fmt = [f'Length: {self.nlabels}', f'labels: {self.labels}'] return f'{cls_name}\n' + '\n'.join(fmt) def __repr__(self): return self.__str__() def __len__(self): if self.isscalar: raise TypeError(f'Scalar {self.__class__.__name__!r} object has ' 'no len()') return self.nlabels def __iter__(self): for item in range(len(self)): yield self.__getitem__(item) @lazyproperty def isscalar(self): """ Whether the instance is scalar (e.g., a single source). """ return self._labels.shape == () @staticmethod def _has_len(value): if isinstance(value, str): return False try: # NOTE: cannot just check for __len__ attribute, because # it could exist, but raise an Exception for scalar objects len(value) except TypeError: return False return True def copy(self): """ Return a deep copy of this SourceCatalog. Returns ------- result : `SourceCatalog` A deep copy of this object. """ return deepcopy(self) @property def extra_properties(self): """ A list of the user-defined source properties. """ return self._extra_properties def add_extra_property(self, name, value, overwrite=False): """ Add a user-defined extra property as a new attribute. For example, the property ``name`` can then be included in the `to_table` ``columns`` keyword list to output the results in the table. The complete list of user-defined extra properties is stored in the `extra_properties` attribute. Parameters ---------- name : str The name of property. The name must not conflict with any of the built-in property names or attributes. value : array_like or float The value to assign. overwrite : bool, option If `True`, will overwrite the existing property ``name``. """ internal_attributes = ((set(self.__dict__.keys()) | set(self._properties)) - set(self.extra_properties)) if name in internal_attributes: raise ValueError(f'{name} cannot be set because it is a ' 'built-in attribute') if not overwrite: if hasattr(self, name): raise ValueError(f'{name} already exists as an attribute. ' 'Set overwrite=True to overwrite an existing ' 'attribute.') if name in self._extra_properties: raise ValueError(f'{name} already exists in the ' '"extra_properties" attribute list.') property_error = False if self.isscalar: # this allows fluxfrac_radius to add len-1 array values for # scalar self if self._has_len(value) and len(value) == 1: value = value[0] if hasattr(value, 'isscalar'): # e.g., Quantity, SkyCoord, Time if not value.isscalar: property_error = True elif not np.isscalar(value): property_error = True elif not self._has_len(value) or len(value) != self.nlabels: property_error = True if property_error: raise ValueError('value must have the same number of elements as ' 'the catalog in order to add it as an extra ' 'property.') setattr(self, name, value) if not overwrite: self._extra_properties.append(name) def remove_extra_property(self, name): """ Remove a user-defined extra property. The property must have been defined using `add_extra_property`. The complete list of user-defined extra properties is stored in the `extra_properties` attribute. Parameters ---------- name : str The name of the property to remove. """ self.remove_extra_properties(name) def remove_extra_properties(self, names): """ Remove user-defined extra properties. The properties must have been defined using `add_extra_property`. The complete list of user-defined extra properties is stored in the `extra_properties` attribute. Parameters ---------- names : list of str or str The names of the properties to remove. """ if isinstance(names, str): names = [names] # we copy the list here to prevent changing the list in-place # during the for loop below, e.g., in case a user inputs # names=self.extra_properties extra_properties = self._extra_properties.copy() for name in names: if name in extra_properties: delattr(self, name) extra_properties.remove(name) else: raise ValueError(f'{name} is not a defined extra property.') self._extra_properties = extra_properties def rename_extra_property(self, name, new_name): """ Rename a user-defined extra property. The renamed property will remain at the same index in the `extra_properties` list. Parameters ---------- name : str The old attribute name. new_name : str The new attribute name. """ self.add_extra_property(new_name, getattr(self, name)) idx = self.extra_properties.index(name) self.remove_extra_property(name) # preserve the order of self.extra_properties self.extra_properties.remove(new_name) self.extra_properties.insert(idx, new_name) @lazyproperty def _null_objects(self): """ Return `None` values. For example, this is used for SkyCoord properties if ``wcs`` is `None`. """ return np.array([None] * self.nlabels) @lazyproperty def _null_values(self): """ Return np.nan values. For example, this is used for background properties if ``background`` is `None`. """ values = np.empty(self.nlabels) values.fill(np.nan) return values @lazyproperty def _data_cutouts(self): """ A list of data cutouts using the segmentation image slices. """ return [self._data[slc] for slc in self._slices_iter] @lazyproperty def _segment_img_cutouts(self): """ A list of segmentation image cutouts using the segmentation image slices. """ return [self._segment_img.data[slc] for slc in self._slices_iter] @lazyproperty def _mask_cutouts(self): """ A list of mask cutouts using the segmentation image slices. If the input ``mask`` is None then a list of None is returned. """ if self._mask is None: return self._null_objects return [self._mask[slc] for slc in self._slices_iter] @lazyproperty def _error_cutouts(self): """ A list of error cutouts using the segmentation image slices. If the input ``mask`` is None then a list of None is returned. """ if self._error is None: return self._null_objects return [self._error[slc] for slc in self._slices_iter] @lazyproperty def _convdata_cutouts(self): """ A list of convolved data cutouts using the segmentation image slices. """ return [self._convolved_data[slc] for slc in self._slices_iter] @lazyproperty def _background_cutouts(self): """ A list of background cutouts using the segmentation image slices. """ if self._background is None: return self._null_objects return [self._background[slc] for slc in self._slices_iter] @staticmethod def _make_cutout_data_mask(data_cutout, mask_cutout): """ Make a cutout data mask, combining both the input ``mask`` and non-finite ``data`` values. """ data_mask = ~np.isfinite(data_cutout) if mask_cutout is not None: data_mask |= mask_cutout return data_mask def _make_cutout_data_masks(self, data_cutouts, mask_cutouts): """ Make a list of cutout data masks, combining both the input ``mask`` and non-finite ``data`` values for each source. """ data_masks = [] for (data_cutout, mask_cutout) in zip(data_cutouts, mask_cutouts, strict=True): data_masks.append(self._make_cutout_data_mask(data_cutout, mask_cutout)) return data_masks @lazyproperty def _cutout_segment_masks(self): """ Cutout boolean mask for source segment. The mask is `True` for all pixels (background and from other source segments) outside of the source segment. """ return [segm != label for label, segm in zip(self.labels, self._segment_img_cutouts, strict=True)] @lazyproperty def _cutout_data_masks(self): """ Cutout boolean mask of non-finite ``data`` values combined with the input ``mask`` array. The mask is `True` for non-finite ``data`` values and where the input ``mask`` is `True`. """ return self._make_cutout_data_masks(self._data_cutouts, self._mask_cutouts) @lazyproperty def _cutout_total_masks(self): """ Boolean mask representing the combination of ``_cutout_segment_masks`` and ``_cutout_data_masks``. This mask is applied to ``data``, ``error``, and ``background`` inputs when calculating properties. """ masks = [] for mask1, mask2 in zip(self._cutout_segment_masks, self._cutout_data_masks, strict=True): masks.append(mask1 | mask2) return masks @lazyproperty def _moment_data_cutouts(self): """ A list of 2D `~numpy.ndarray` cutouts from the (convolved) data. The following pixels are set to zero in these arrays: * pixels outside of the source segment * any masked pixels from the input ``mask`` * invalid convolved data values (NaN and inf) * negative convolved data values; negative pixels (especially at large radii) can give image moments that have negative variances. These arrays are used to derive moment-based properties. """ cutouts = [] for convdata_cutout, mask_cutout, segmmask_cutout in zip( self._convdata_cutouts, self._mask_cutouts, self._cutout_segment_masks, strict=True): convdata_mask = (~np.isfinite(convdata_cutout) | (convdata_cutout < 0) | segmmask_cutout) if self._mask is not None: convdata_mask |= mask_cutout cutout = convdata_cutout.copy() cutout[convdata_mask] = 0.0 cutouts.append(cutout) return cutouts def _prepare_cutouts(self, arrays, units=True, masked=False, dtype=None): """ Prepare cutouts by applying optional units, masks, or dtype. """ if units and masked: raise ValueError('Both units and masked cannot be True') 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 get_label(self, label): """ Return a new `SourceCatalog` object for the input ``label`` only. Parameters ---------- label : int The source label. Returns ------- cat : `SourceCatalog` A new `SourceCatalog` object containing only the source with the input ``label``. """ return self.get_labels(label) def get_labels(self, labels): """ Return a new `SourceCatalog` object for the input ``labels`` only. Parameters ---------- labels : list, tuple, or `~numpy.ndarray` of int The source label(s). Returns ------- cat : `SourceCatalog` A new `SourceCatalog` object containing only the sources with the input ``labels``. """ self._segment_img.check_labels(labels) sorter = np.argsort(self.labels) indices = sorter[np.searchsorted(self.labels, labels, sorter=sorter)] return self[indices] def to_table(self, columns=None): """ Create a `~astropy.table.QTable` of source properties. Parameters ---------- columns : str, list of str, `None`, optional Names of columns, in order, to include in the output `~astropy.table.QTable`. The allowed column names are any of the `SourceCatalog` properties or custom properties added using `add_extra_property`. If ``columns`` is `None`, then a default list of scalar-valued properties (as defined by the ``default_columns`` attribute) will be used. Returns ------- table : `~astropy.table.QTable` A table of sources properties with one row per source. """ if columns is None: table_columns = self.default_columns elif isinstance(columns, str): table_columns = [columns] else: table_columns = columns tbl = QTable() tbl.meta.update(self.meta) # keep tbl.meta type for column in table_columns: values = getattr(self, column) # column assignment requires an object with a length if self.isscalar: values = (values,) tbl[column] = values return tbl @lazyproperty def nlabels(self): """ The number of source labels. """ return len(self.labels) @property @as_scalar def label(self): """ The source label number(s). This label number corresponds to the assigned pixel value in the `~photutils.segmentation.SegmentationImage`. """ return self._labels @property def labels(self): """ The source label number(s), always as an iterable `~numpy.ndarray`. This label number corresponds to the assigned pixel value in the `~photutils.segmentation.SegmentationImage`. """ labels = self.label if self.isscalar: labels = np.array((labels,)) return labels @property @as_scalar def slices(self): """ A tuple of slice objects defining the minimal bounding box of the source. """ return self._slices @lazyproperty def _slices_iter(self): """ A tuple of slice objects defining the minimal bounding box of the source, always as an iterable. """ _slices = self.slices if self.isscalar: _slices = (_slices,) return _slices @lazyproperty @as_scalar def segment(self): """ A 2D `~numpy.ndarray` cutout of the segmentation image using the minimal bounding box of the source. """ return self._prepare_cutouts(self._segment_img_cutouts, units=False, masked=False) @lazyproperty @as_scalar def segment_ma(self): """ A 2D `~numpy.ma.MaskedArray` cutout of the segmentation image using the minimal bounding box of the source. The mask is `True` for pixels outside of the source segment (labeled region of interest), masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). """ return self._prepare_cutouts(self._segment_img_cutouts, units=False, masked=True) @lazyproperty @as_scalar def data(self): """ A 2D `~numpy.ndarray` cutout from the data using the minimal bounding box of the source. """ return self._prepare_cutouts(self._data_cutouts, units=True, masked=False, dtype=float) @lazyproperty @as_scalar def data_ma(self): """ A 2D `~numpy.ma.MaskedArray` cutout from the data using the minimal bounding box of the source. The mask is `True` for pixels outside of the source segment (labeled region of interest), masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). """ return self._prepare_cutouts(self._data_cutouts, units=False, masked=True, dtype=float) @lazyproperty @as_scalar def convdata(self): """ A 2D `~numpy.ndarray` cutout from the convolved data using the minimal bounding box of the source. """ return self._prepare_cutouts(self._convdata_cutouts, units=True, masked=False, dtype=float) @lazyproperty @as_scalar def convdata_ma(self): """ A 2D `~numpy.ma.MaskedArray` cutout from the convolved data using the minimal bounding box of the source. The mask is `True` for pixels outside of the source segment (labeled region of interest), masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). """ return self._prepare_cutouts(self._convdata_cutouts, units=False, masked=True, dtype=float) @lazyproperty @as_scalar def error(self): """ A 2D `~numpy.ndarray` cutout from the error array using the minimal bounding box of the source. """ if self._error is None: return self._null_objects return self._prepare_cutouts(self._error_cutouts, units=True, masked=False) @lazyproperty @as_scalar def error_ma(self): """ A 2D `~numpy.ma.MaskedArray` cutout from the error array using the minimal bounding box of the source. The mask is `True` for pixels outside of the source segment (labeled region of interest), masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). """ if self._error is None: return self._null_objects return self._prepare_cutouts(self._error_cutouts, units=False, masked=True) @lazyproperty @as_scalar def background(self): """ A 2D `~numpy.ndarray` cutout from the background array using the minimal bounding box of the source. """ if self._background is None: return self._null_objects return self._prepare_cutouts(self._background_cutouts, units=True, masked=False) @lazyproperty @as_scalar def background_ma(self): """ A 2D `~numpy.ma.MaskedArray` cutout from the background array. using the minimal bounding box of the source. The mask is `True` for pixels outside of the source segment (labeled region of interest), masked pixels from the ``mask`` input, or any non-finite ``data`` values (NaN and inf). """ if self._background is None: return self._null_objects return self._prepare_cutouts(self._background_cutouts, units=False, masked=True) @lazyproperty def _all_masked(self): """ True if all pixels over the source segment are masked. """ return np.array([np.all(mask) for mask in self._cutout_total_masks]) def _get_values(self, array): """ Get a 1D array of unmasked values from the input array within the source segment. An array with a single NaN is returned for completely-masked sources. """ if self.isscalar: array = (array,) return [arr.compressed() if len(arr.compressed()) > 0 else np.array([np.nan]) for arr in array] @lazyproperty def _data_values(self): """ A 1D array of unmasked data values. An array with a single NaN is returned for completely-masked sources. """ return self._get_values(self.data_ma) @lazyproperty def _error_values(self): """ A 1D array of unmasked error values. An array with a single NaN is returned for completely-masked sources. """ return self._get_values(self.error_ma) @lazyproperty def _background_values(self): """ A 1D array of unmasked background values. An array with a single NaN is returned for completely-masked sources. """ return self._get_values(self.background_ma) @lazyproperty @use_detcat @as_scalar def moments(self): """ Spatial moments up to 3rd order of the source. """ return np.array([_moments(arr, order=3) for arr in self._moment_data_cutouts]) @lazyproperty @use_detcat @as_scalar def moments_central(self): """ Central moments (translation invariant) of the source up to 3rd order. """ cutout_centroid = self.cutout_centroid if self.isscalar: cutout_centroid = cutout_centroid[np.newaxis, :] return np.array([_moments_central(arr, center=(xcen_, ycen_), order=3) for arr, xcen_, ycen_ in zip(self._moment_data_cutouts, cutout_centroid[:, 0], cutout_centroid[:, 1], strict=True)]) @lazyproperty @use_detcat @as_scalar def cutout_centroid(self): """ The ``(x, y)`` coordinate, relative to the cutout data, of the centroid within the isophotal source segment. The centroid is computed as the center of mass of the unmasked pixels within the source segment. """ moments = self.moments if self.isscalar: moments = moments[np.newaxis, :] # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) ycentroid = moments[:, 1, 0] / moments[:, 0, 0] xcentroid = moments[:, 0, 1] / moments[:, 0, 0] return np.transpose((xcentroid, ycentroid)) @lazyproperty @use_detcat @as_scalar def centroid(self): """ The ``(x, y)`` coordinate of the centroid within the isophotal source segment. The centroid is computed as the center of mass of the unmasked pixels within the source segment. """ origin = np.transpose((self.bbox_xmin, self.bbox_ymin)) return self.cutout_centroid + origin @lazyproperty @use_detcat def _xcentroid(self): """ The ``x`` coordinate of the `centroid` within the source segment, always as an iterable. """ if self.isscalar: xcentroid = self.centroid[0:1] # scalar array else: xcentroid = self.centroid[:, 0] return xcentroid @lazyproperty @use_detcat @as_scalar def xcentroid(self): """ The ``x`` coordinate of the `centroid` within the source segment. The centroid is computed as the center of mass of the unmasked pixels within the source segment. """ return self._xcentroid @lazyproperty @use_detcat def _ycentroid(self): """ The ``y`` coordinate of the `centroid` within the source segment, always as an iterable. """ if self.isscalar: ycentroid = self.centroid[1:2] # scalar array else: ycentroid = self.centroid[:, 1] return ycentroid @lazyproperty @use_detcat @as_scalar def ycentroid(self): """ The ``y`` coordinate of the `centroid` within the source segment. The centroid is computed as the center of mass of the unmasked pixels within the source segment. """ return self._ycentroid @lazyproperty @use_detcat @as_scalar def centroid_win(self): """ The ``(x, y)`` coordinate of the "windowed" centroid. The window centroid is computed using an iterative algorithm to derive a more accurate centroid. It is equivalent to `SourceExtractor`_'s XWIN_IMAGE and YWIN_IMAGE parameters. Notes ----- On each iteration, the centroid is calculated using all pixels within a circular aperture of ``4 * sigma`` from the current position, weighting pixel values with a 2D Gaussian with a standard deviation of ``sigma``. ``sigma`` is the half-light radius (i.e., ``flucfrac_radius(0.5)``) times (2.0 / 2.35). A minimum half-light radius of 0.5 pixels is used. Iteration stops when the change in centroid position falls below a pre-defined threshold or a maximum number of iterations is reached. If the windowed centroid falls outside of the 1-sigma ellipse shape based on the image moments, then the isophotal `centroid` will be used instead. """ radius_hl = self.fluxfrac_radius(0.5).value if self.isscalar: radius_hl = np.array([radius_hl]) min_radius = 0.5 # define minimum half-light radius mask = (radius_hl < min_radius) | ~np.isfinite(radius_hl) radius_hl[mask] = min_radius kwargs = self._apermask_kwargs['cen_win'] labels = self.labels if self.progress_bar: # pragma: no cover desc = 'centroid_win' labels = add_progress_bar(labels, desc=desc) xcen_win = [] ycen_win = [] for label, xcen, ycen, rad_hl in zip(labels, self._xcentroid, self._ycentroid, radius_hl, strict=True): if np.any(~np.isfinite((xcen, ycen))): xcen_win.append(np.nan) ycen_win.append(np.nan) continue sigma = 2.0 * rad_hl * gaussian_fwhm_to_sigma sigma2 = sigma**2 radius = 4.0 * sigma iter_ = 0 dcen = 1 max_iters = 16 centroid_threshold = 0.0001 while iter_ < max_iters and dcen > centroid_threshold: aperture = CircularAperture((xcen, ycen), radius) aperture_mask = aperture.to_mask(**kwargs) # for consistency with the isophotal centroid, a local # background is not subtracted here data, _, mask, cutout_xycen, slc_sm = self._make_aperture_data( label, xcen, ycen, aperture_mask.bbox, 0.0, make_error=False) if data is None: # return NaN if centroid moves the aperture # completely off the image xcen = np.nan ycen = np.nan break aperture_weights = aperture_mask.data[slc_sm] # define a 2D Gaussian weight array xvals = np.arange(data.shape[1]) - cutout_xycen[0] yvals = np.arange(data.shape[0]) - cutout_xycen[1] xx, yy = np.meshgrid(xvals, yvals) rr2 = xx**2 + yy**2 gweight = np.exp(-rr2 / (2.0 * sigma2)) # ignore multiplication with non-finite values # and ignore divide-by-zero if moments[0, 0] = 0 with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) data = data * aperture_weights * gweight data[mask] = 0.0 moments = _moments_central(data, center=cutout_xycen, order=1) dy = moments[1, 0] / moments[0, 0] dx = moments[0, 1] / moments[0, 0] dcen = np.sqrt(dx**2 + dy**2) xcen += dx * 2.0 ycen += dy * 2.0 iter_ += 1 xcen_win.append(xcen) ycen_win.append(ycen) xcen_win = np.array(xcen_win) ycen_win = np.array(ycen_win) # reset to the isophotal centroid if the windowed centroid is # outside of the 1-sigma ellipse dx = self._xcentroid - xcen_win dy = self._ycentroid - ycen_win cxx = self.cxx.value cxy = self.cxy.value cyy = self.cyy.value if self.isscalar: cxx = (cxx,) cxy = (cxy,) cyy = (cyy,) mask = ((cxx * dx**2 + cxy * dx * dy + cyy * dy**2) > 1) mask |= (np.isnan(xcen_win) | np.isnan(ycen_win)) if np.any(mask): xcen_win[mask] = self._xcentroid[mask] ycen_win[mask] = self._ycentroid[mask] return np.transpose((xcen_win, ycen_win)) @lazyproperty @use_detcat @as_scalar def xcentroid_win(self): """ The ``x`` coordinate of the "windowed" centroid (`centroid_win`). The window centroid is computed using an iterative algorithm to derive a more accurate centroid. It is equivalent to `SourceExtractor`_'s XWIN_IMAGE parameters. """ if self.isscalar: xcentroid = self.centroid_win[0] # scalar array else: xcentroid = self.centroid_win[:, 0] return xcentroid @lazyproperty @use_detcat @as_scalar def ycentroid_win(self): """ The ``y`` coordinate of the "windowed" centroid (`centroid_win`). The window centroid is computed using an iterative algorithm to derive a more accurate centroid. It is equivalent to `SourceExtractor`_'s YWIN_IMAGE parameters. """ if self.isscalar: ycentroid = self.centroid_win[1] # scalar array else: ycentroid = self.centroid_win[:, 1] return ycentroid @lazyproperty @use_detcat @as_scalar def cutout_centroid_win(self): """ The ``(x, y)`` coordinate, relative to the cutout data, of the "windowed" centroid. The window centroid is computed using an iterative algorithm to derive a more accurate centroid. It is equivalent to `SourceExtractor`_'s XWIN_IMAGE and YWIN_IMAGE parameters. See `centroid_win` for further details about the algorithm. """ origin = np.transpose((self.bbox_xmin, self.bbox_ymin)) return self.centroid_win - origin @lazyproperty @use_detcat @as_scalar def cutout_centroid_quad(self): """ The ``(x, y)`` centroid coordinate, relative to the cutout data, calculated by fitting a 2D quadratic polynomial to the unmasked pixels in the source segment. Notes ----- `~photutils.centroids.centroid_quadratic` is used to calculate the centroid with ``fit_boxsize=3``. Because this centroid is based on fitting data, it can fail for many reasons including: * quadratic fit failed * quadratic fit does not have a maximum * quadratic fit maximum falls outside image * not enough unmasked data points (6 are required) In these cases, then the isophotal `centroid` will be used instead. Also note that a fit is not performed if the maximum data value is at the edge of the source segment. In this case, the position of the maximum pixel will be returned. """ centroid_quad = [] with warnings.catch_warnings(): # ignore fit warnings: # - quadratic fit failed # - quadratic fit does not have a maximum # - quadratic fit maximum falls outside image # - not enough unmasked data points (6 are required) # - maximum value is at the edge of the data warnings.simplefilter('ignore', AstropyUserWarning) cutouts = self._data_cutouts if self.progress_bar: # pragma: no cover desc = 'centroid_quad' cutouts = add_progress_bar(cutouts, desc=desc) for data, mask in zip(cutouts, self._cutout_total_masks, strict=True): try: centroid = centroid_quadratic(data, mask=mask, fit_boxsize=3) except ValueError: centroid = (np.nan, np.nan) centroid_quad.append(centroid) centroid_quad = np.array(centroid_quad) # use the segment barycenter if fit returned NaN nan_mask = (np.isnan(centroid_quad[:, 0]) | np.isnan(centroid_quad[:, 1])) if np.any(nan_mask): centroid_quad[nan_mask] = self.cutout_centroid[nan_mask] return centroid_quad @lazyproperty @use_detcat @as_scalar def centroid_quad(self): """ The ``(x, y)`` centroid coordinate, calculated by fitting a 2D quadratic polynomial to the unmasked pixels in the source segment. Notes ----- `~photutils.centroids.centroid_quadratic` is used to calculate the centroid with ``fit_boxsize=3``. Because this centroid is based on fitting data, it can fail for many reasons, returning (np.nan, np.nan): * quadratic fit failed * quadratic fit does not have a maximum * quadratic fit maximum falls outside image * not enough unmasked data points (6 are required) Also note that a fit is not performed if the maximum data value is at the edge of the source segment. In this case, the position of the maximum pixel will be returned. """ origin = np.transpose((self.bbox_xmin, self.bbox_ymin)) return self.cutout_centroid_quad + origin @lazyproperty @use_detcat @as_scalar def xcentroid_quad(self): """ The ``x`` coordinate of the centroid (`centroid_quad`), calculated by fitting a 2D quadratic polynomial to the unmasked pixels in the source segment. """ if self.isscalar: xcentroid = self.centroid_quad[0] # scalar array else: xcentroid = self.centroid_quad[:, 0] return xcentroid @lazyproperty @use_detcat @as_scalar def ycentroid_quad(self): """ The ``y`` coordinate of the centroid (`centroid_quad`), calculated by fitting a 2D quadratic polynomial to the unmasked pixels in the source segment. """ if self.isscalar: ycentroid = self.centroid_quad[1] # scalar array else: ycentroid = self.centroid_quad[:, 1] return ycentroid @lazyproperty @use_detcat @as_scalar def sky_centroid(self): """ The sky coordinate of the `centroid` within the source segment, returned as a `~astropy.coordinates.SkyCoord` object. The output coordinate frame is the same as the input ``wcs``. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self.wcs.pixel_to_world(self.xcentroid, self.ycentroid) @lazyproperty @use_detcat @as_scalar def sky_centroid_icrs(self): """ The sky coordinate in the International Celestial Reference System (ICRS) frame of the `centroid` within the source segment, returned as a `~astropy.coordinates.SkyCoord` object. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self.sky_centroid.icrs @lazyproperty @use_detcat @as_scalar def sky_centroid_win(self): """ The sky coordinate of the "windowed" centroid (`centroid_win`) within the source segment, returned as a `~astropy.coordinates.SkyCoord` object. The output coordinate frame is the same as the input ``wcs``. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self.wcs.pixel_to_world(self.xcentroid_win, self.ycentroid_win) @lazyproperty @use_detcat @as_scalar def sky_centroid_quad(self): """ The sky coordinate of the centroid (`centroid_quad`), calculated by fitting a 2D quadratic polynomial to the unmasked pixels in the source segment. The output coordinate frame is the same as the input ``wcs``. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self.wcs.pixel_to_world(self.xcentroid_quad, self.ycentroid_quad) @lazyproperty @use_detcat def _bbox(self): """ The `~photutils.aperture.BoundingBox` of the minimal rectangular region containing the source segment, always as an iterable. """ return [BoundingBox(ixmin=slc[1].start, ixmax=slc[1].stop, iymin=slc[0].start, iymax=slc[0].stop) for slc in self._slices_iter] @lazyproperty @use_detcat @as_scalar def bbox(self): """ The `~photutils.aperture.BoundingBox` of the minimal rectangular region containing the source segment. """ return self._bbox @lazyproperty @use_detcat @as_scalar def bbox_xmin(self): """ The minimum ``x`` pixel index within the minimal bounding box containing the source segment. """ return np.array([slc[1].start for slc in self._slices_iter]) @lazyproperty @use_detcat @as_scalar def bbox_xmax(self): """ The maximum ``x`` pixel index within the minimal bounding box containing the source segment. Note that this value is inclusive, unlike numpy slice indices. """ return np.array([slc[1].stop - 1 for slc in self._slices_iter]) @lazyproperty @use_detcat @as_scalar def bbox_ymin(self): """ The minimum ``y`` pixel index within the minimal bounding box containing the source segment. """ return np.array([slc[0].start for slc in self._slices_iter]) @lazyproperty @use_detcat @as_scalar def bbox_ymax(self): """ The maximum ``y`` pixel index within the minimal bounding box containing the source segment. Note that this value is inclusive, unlike numpy slice indices. """ return np.array([slc[0].stop - 1 for slc in self._slices_iter]) @lazyproperty @use_detcat def _bbox_corner_ll(self): """ Lower-left *outside* pixel corner location (not index). """ return np.array([(bbox_.ixmin - 0.5, bbox_.iymin - 0.5) for bbox_ in self._bbox]) @lazyproperty @use_detcat def _bbox_corner_ul(self): """ Upper-left *outside* pixel corner location (not index). """ return np.array([(bbox_.ixmin - 0.5, bbox_.iymax + 0.5) for bbox_ in self._bbox]) @lazyproperty @use_detcat def _bbox_corner_lr(self): """ Lower-right *outside* pixel corner location (not index). """ return np.array([(bbox_.ixmax + 0.5, bbox_.iymin - 0.5) for bbox_ in self._bbox]) @lazyproperty @use_detcat def _bbox_corner_ur(self): """ Upper-right *outside* pixel corner location (not index). """ return np.array([(bbox_.ixmax + 0.5, bbox_.iymax + 0.5) for bbox_ in self._bbox]) @lazyproperty @use_detcat @as_scalar def sky_bbox_ll(self): """ The sky coordinates of the lower-left corner vertex of the minimal bounding box of the source segment, returned as a `~astropy.coordinates.SkyCoord` object. The bounding box encloses all of the source segment pixels in their entirety, thus the vertices are at the pixel *corners*, not their centers. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self.wcs.pixel_to_world(*np.transpose(self._bbox_corner_ll)) @lazyproperty @use_detcat @as_scalar def sky_bbox_ul(self): """ The sky coordinates of the upper-left corner vertex of the minimal bounding box of the source segment, returned as a `~astropy.coordinates.SkyCoord` object. The bounding box encloses all of the source segment pixels in their entirety, thus the vertices are at the pixel *corners*, not their centers. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self.wcs.pixel_to_world(*np.transpose(self._bbox_corner_ul)) @lazyproperty @use_detcat @as_scalar def sky_bbox_lr(self): """ The sky coordinates of the lower-right corner vertex of the minimal bounding box of the source segment, returned as a `~astropy.coordinates.SkyCoord` object. The bounding box encloses all of the source segment pixels in their entirety, thus the vertices are at the pixel *corners*, not their centers. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self.wcs.pixel_to_world(*np.transpose(self._bbox_corner_lr)) @lazyproperty @use_detcat @as_scalar def sky_bbox_ur(self): """ The sky coordinates of the upper-right corner vertex of the minimal bounding box of the source segment, returned as a `~astropy.coordinates.SkyCoord` object. The bounding box encloses all of the source segment pixels in their entirety, thus the vertices are at the pixel *corners*, not their centers. `None` if ``wcs`` is not input. """ if self.wcs is None: return self._null_objects return self.wcs.pixel_to_world(*np.transpose(self._bbox_corner_ur)) @lazyproperty @as_scalar def min_value(self): """ The minimum pixel value of the ``data`` within the source segment. """ values = np.array([np.min(array) for array in self._data_values]) values -= self._local_background if self._data_unit is not None: values <<= self._data_unit return values @lazyproperty @as_scalar def max_value(self): """ The maximum pixel value of the ``data`` within the source segment. """ values = np.array([np.max(array) for array in self._data_values]) values -= self._local_background if self._data_unit is not None: values <<= self._data_unit return values @lazyproperty @as_scalar def cutout_minval_index(self): """ The ``(y, x)`` coordinate, relative to the cutout data, of the minimum pixel value of the ``data`` within the source segment. If there are multiple occurrences of the minimum value, only the first occurrence is returned. """ data = self.data_ma if self.isscalar: data = (data,) idx = [] for arr in data: if np.all(arr.mask): idx.append((np.nan, np.nan)) else: idx.append(np.unravel_index(np.argmin(arr), arr.shape)) return np.array(idx) @lazyproperty @as_scalar def cutout_maxval_index(self): """ The ``(y, x)`` coordinate, relative to the cutout data, of the maximum pixel value of the ``data`` within the source segment. If there are multiple occurrences of the maximum value, only the first occurrence is returned. """ data = self.data_ma if self.isscalar: data = (data,) idx = [] for arr in data: if np.all(arr.mask): idx.append((np.nan, np.nan)) else: idx.append(np.unravel_index(np.argmax(arr), arr.shape)) return np.array(idx) @lazyproperty @as_scalar def minval_index(self): """ The ``(y, x)`` coordinate of the minimum pixel value of the ``data`` within the source segment. If there are multiple occurrences of the minimum value, only the first occurrence is returned. """ index = self.cutout_minval_index if self.isscalar: index = (index,) out = [] for idx, slc in zip(index, self._slices_iter, strict=True): out.append((idx[0] + slc[0].start, idx[1] + slc[1].start)) return np.array(out) @lazyproperty @as_scalar def maxval_index(self): """ The ``(y, x)`` coordinate of the maximum pixel value of the ``data`` within the source segment. If there are multiple occurrences of the maximum value, only the first occurrence is returned. """ index = self.cutout_maxval_index if self.isscalar: index = (index,) out = [] for idx, slc in zip(index, self._slices_iter, strict=True): out.append((idx[0] + slc[0].start, idx[1] + slc[1].start)) return np.array(out) @lazyproperty @as_scalar def minval_xindex(self): """ The ``x`` coordinate of the minimum pixel value of the ``data`` within the source segment. If there are multiple occurrences of the minimum value, only the first occurrence is returned. """ if self.isscalar: xidx = self.minval_index[1] else: xidx = self.minval_index[:, 1] return xidx @lazyproperty @as_scalar def minval_yindex(self): """ The ``y`` coordinate of the minimum pixel value of the ``data`` within the source segment. If there are multiple occurrences of the minimum value, only the first occurrence is returned. """ if self.isscalar: yidx = self.minval_index[0] else: yidx = self.minval_index[:, 0] return yidx @lazyproperty @as_scalar def maxval_xindex(self): """ The ``x`` coordinate of the maximum pixel value of the ``data`` within the source segment. If there are multiple occurrences of the maximum value, only the first occurrence is returned. """ if self.isscalar: xidx = self.maxval_index[1] else: xidx = self.maxval_index[:, 1] return xidx @lazyproperty @as_scalar def maxval_yindex(self): """ The ``y`` coordinate of the maximum pixel value of the ``data`` within the source segment. If there are multiple occurrences of the maximum value, only the first occurrence is returned. """ if self.isscalar: yidx = self.maxval_index[0] else: yidx = self.maxval_index[:, 0] return yidx @lazyproperty @as_scalar def segment_flux(self): r""" The sum of the unmasked ``data`` values within the source segment. .. math:: F = \sum_{i \in S} I_i where :math:`F` is ``segment_flux``, :math:`I_i` is the background-subtracted ``data``, and :math:`S` are the unmasked pixels in the source segment. Non-finite pixel values (NaN and inf) are excluded (automatically masked). """ localbkg = self._local_background if self.isscalar: localbkg = localbkg[0] source_sum = np.array([np.sum(arr) for arr in self._data_values]) source_sum -= self.area.value * localbkg if self._data_unit is not None: source_sum <<= self._data_unit return source_sum @lazyproperty @as_scalar def segment_fluxerr(self): r""" The uncertainty of `segment_flux` , propagated from the input ``error`` array. ``segment_fluxerr`` is the quadrature sum of the total errors over the unmasked pixels within the source segment: .. math:: \Delta F = \sqrt{\sum_{i \in S} \sigma_{\mathrm{tot}, i}^2} where :math:`\Delta F` is the `segment_flux`, :math:`\sigma_{\mathrm{tot, i}}` are the pixel-wise total errors (``error``), and :math:`S` are the unmasked pixels in the source segment. Pixel values that are masked in the input ``data``, including any non-finite pixel values (NaN and inf) that are automatically masked, are also masked in the error array. """ if self._error is None: err = self._null_values else: err = np.sqrt(np.array([np.sum(arr**2) for arr in self._error_values])) if self._data_unit is not None: err <<= self._data_unit return err @lazyproperty @as_scalar def background_sum(self): """ The sum of ``background`` values within the source segment. Pixel values that are masked in the input ``data``, including any non-finite pixel values (NaN and inf) that are automatically masked, are also masked in the background array. """ if self._background is None: bkg_sum = self._null_values else: bkg_sum = np.array([np.sum(arr) for arr in self._background_values]) if self._data_unit is not None: bkg_sum <<= self._data_unit return bkg_sum @lazyproperty @as_scalar def background_mean(self): """ The mean of ``background`` values within the source segment. Pixel values that are masked in the input ``data``, including any non-finite pixel values (NaN and inf) that are automatically masked, are also masked in the background array. """ if self._background is None: bkg_mean = self._null_values else: bkg_mean = np.array([np.mean(arr) for arr in self._background_values]) if self._data_unit is not None: bkg_mean <<= self._data_unit return bkg_mean @lazyproperty @as_scalar def background_centroid(self): """ The value of the per-pixel ``background`` at the position of the isophotal (center-of-mass) `centroid`. If ``detection_cat`` is input, then its `centroid` will be used. The background value at fractional position values are determined using bilinear interpolation. """ if self._background is None: bkg = self._null_values else: xcen = self._xcentroid ycen = self._ycentroid bkg = map_coordinates(self._background, (xcen, ycen), order=1, mode='nearest') mask = np.isfinite(xcen) & np.isfinite(ycen) bkg[~mask] = np.nan if self._data_unit is not None: bkg <<= self._data_unit return bkg @lazyproperty @use_detcat @as_scalar def segment_area(self): """ The total area of the source segment in units of pixels**2. This area is simply the area of the source segment from the input ``segment_img``. It does not take into account any data masking (i.e., a ``mask`` input to `SourceCatalog` or invalid ``data`` values). """ areas = [] for label, slices in zip(self.labels, self._slices_iter, strict=True): areas.append(np.count_nonzero(self._segment_img[slices] == label)) return np.array(areas) << (u.pix**2) @lazyproperty @use_detcat @as_scalar def area(self): """ The total unmasked area of the source in units of pixels**2. Note that the source area may be smaller than its `segment_area` if a mask is input to `SourceCatalog` or if the ``data`` within the segment contains invalid values (NaN and inf). """ areas = np.array([arr.size for arr in self._data_values]).astype(float) areas[self._all_masked] = np.nan return areas << (u.pix**2) @lazyproperty @use_detcat @as_scalar def equivalent_radius(self): """ The radius of a circle with the same `area` as the source segment. """ return np.sqrt(self.area / np.pi) @lazyproperty @use_detcat @as_scalar def perimeter(self): """ The perimeter of the source segment, approximated as the total length of lines connecting the centers of the border pixels defined by a 4-pixel connectivity. If any masked pixels make holes within the source segment, then the perimeter around the inner hole (e.g., an annulus) will also contribute to the total perimeter. References ---------- .. [1] K. Benkrid, D. Crookes, and A. Benkrid. "Design and FPGA Implementation of a Perimeter Estimator". Proceedings of the Irish Machine Vision and Image Processing Conference, pp. 51-57 (2000). """ footprint = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]]) kernel = np.array([[10, 2, 10], [2, 1, 2], [10, 2, 10]]) size = 34 weights = np.zeros(size, dtype=float) weights[[5, 7, 15, 17, 25, 27]] = 1.0 weights[[21, 33]] = np.sqrt(2.0) weights[[13, 23]] = (1 + np.sqrt(2.0)) / 2.0 perimeter = [] for mask in self._cutout_total_masks: if np.all(mask): perimeter.append(np.nan) continue data = ~mask data_eroded = binary_erosion(data, footprint, border_value=0) border = np.logical_xor(data, data_eroded).astype(int) perimeter_data = convolve(border, kernel, mode='constant', cval=0) perimeter_hist = np.bincount(perimeter_data.ravel(), minlength=size) perimeter.append(perimeter_hist[0:size] @ weights) return np.array(perimeter) * u.pix @lazyproperty @use_detcat @as_scalar def inertia_tensor(self): """ The inertia tensor of the source for the rotation around its center of mass. """ moments = self.moments_central if self.isscalar: moments = moments[np.newaxis, :] mu_02 = moments[:, 0, 2] mu_11 = -moments[:, 1, 1] mu_20 = moments[:, 2, 0] tensor = np.array([mu_02, mu_11, mu_11, mu_20]).swapaxes(0, 1) return tensor.reshape((tensor.shape[0], 2, 2)) * u.pix**2 @lazyproperty @use_detcat def _covariance(self): """ The covariance matrix of the 2D Gaussian function that has the same second-order moments as the source, always as an iterable. """ moments = self.moments_central if self.isscalar: moments = moments[np.newaxis, :] # ignore divide-by-zero RuntimeWarning with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) mu_norm = moments / moments[:, 0, 0][:, np.newaxis, np.newaxis] covar = np.array([mu_norm[:, 0, 2], mu_norm[:, 1, 1], mu_norm[:, 1, 1], mu_norm[:, 2, 0]]).swapaxes(0, 1) covar = covar.reshape((covar.shape[0], 2, 2)) # Modify the covariance matrix in the case of "infinitely" thin # detections. This follows SourceExtractor's prescription of # incrementally increasing the diagonal elements by 1/12. delta = 1.0 / 12 delta2 = delta**2 # ignore RuntimeWarning from NaN values in covar with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) covar_det = np.linalg.det(covar) # covariance should be positive semidefinite idx = np.where(covar_det < 0)[0] covar[idx] = np.array([[np.nan, np.nan], [np.nan, np.nan]]) idx = np.where(covar_det < delta2)[0] while idx.size > 0: covar[idx, 0, 0] += delta covar[idx, 1, 1] += delta covar_det = np.linalg.det(covar) idx = np.where(covar_det < delta2)[0] return covar @lazyproperty @use_detcat @as_scalar def covariance(self): """ The covariance matrix of the 2D Gaussian function that has the same second-order moments as the source. """ return self._covariance * (u.pix**2) @lazyproperty @use_detcat @as_scalar def covariance_eigvals(self): """ The two eigenvalues of the `covariance` matrix in decreasing order. """ eigvals = np.empty((self.nlabels, 2)) eigvals.fill(np.nan) # np.linalg.eigvals requires finite input values idx = np.unique(np.where(np.isfinite(self._covariance))[0]) eigvals[idx] = np.linalg.eigvals(self._covariance[idx]) # check for negative variance # (just in case covariance matrix is not positive semidefinite) idx2 = np.unique(np.where(eigvals < 0)[0]) # pragma: no cover eigvals[idx2] = (np.nan, np.nan) # pragma: no cover # sort each eigenvalue pair in descending order eigvals.sort(axis=1) eigvals = np.fliplr(eigvals) return eigvals * u.pix**2 @lazyproperty @use_detcat @as_scalar def semimajor_sigma(self): """ The 1-sigma standard deviation along the semimajor axis of the 2D Gaussian function that has the same second-order central moments as the source. """ eigvals = self.covariance_eigvals if self.isscalar: eigvals = eigvals[np.newaxis, :] # this matches SourceExtractor's A parameter return np.sqrt(eigvals[:, 0]) @lazyproperty @use_detcat @as_scalar def semiminor_sigma(self): """ The 1-sigma standard deviation along the semiminor axis of the 2D Gaussian function that has the same second-order central moments as the source. """ eigvals = self.covariance_eigvals if self.isscalar: eigvals = eigvals[np.newaxis, :] # this matches SourceExtractor's B parameter return np.sqrt(eigvals[:, 1]) @lazyproperty @use_detcat @as_scalar def fwhm(self): r""" The circularized full width at half maximum (FWHM) of the 2D Gaussian function that has the same second-order central moments as the source. .. math:: \mathrm{FWHM} & = 2 \sqrt{2 \ln(2)} \sqrt{0.5 (a^2 + b^2)} \\ & = 2 \sqrt{\ln(2) \ (a^2 + b^2)} where :math:`a` and :math:`b` are the 1-sigma lengths of the semimajor (`semimajor_sigma`) and semiminor (`semiminor_sigma`) axes, respectively. """ return 2.0 * np.sqrt(np.log(2.0) * (self.semimajor_sigma**2 + self.semiminor_sigma**2)) @lazyproperty @use_detcat @as_scalar def orientation(self): """ The angle between the ``x`` axis and the major axis of the 2D Gaussian function that has the same second-order moments as the source. The angle increases in the counter-clockwise direction. """ covar = self._covariance orient_radians = 0.5 * np.arctan2(2.0 * covar[:, 0, 1], (covar[:, 0, 0] - covar[:, 1, 1])) return orient_radians * 180.0 / np.pi * u.deg @lazyproperty @use_detcat @as_scalar def eccentricity(self): r""" The eccentricity of the 2D Gaussian function that has the same second-order moments as the source. The eccentricity is the fraction of the distance along the semimajor axis at which the focus lies. .. math:: e = \sqrt{1 - \frac{b^2}{a^2}} where :math:`a` and :math:`b` are the lengths of the semimajor and semiminor axes, respectively. """ semimajor_var, semiminor_var = np.transpose(self.covariance_eigvals) return np.sqrt(1.0 - (semiminor_var / semimajor_var)) @lazyproperty @use_detcat @as_scalar def elongation(self): r""" The ratio of the lengths of the semimajor and semiminor axes. .. math:: \mathrm{elongation} = \frac{a}{b} where :math:`a` and :math:`b` are the lengths of the semimajor and semiminor axes, respectively. """ return self.semimajor_sigma / self.semiminor_sigma @lazyproperty @use_detcat @as_scalar def ellipticity(self): r""" 1.0 minus the ratio of the lengths of the semimajor and semiminor axes. .. math:: \mathrm{ellipticity} = \frac{a - b}{a} = 1 - \frac{b}{a} where :math:`a` and :math:`b` are the lengths of the semimajor and semiminor axes, respectively. """ return 1.0 - (self.semiminor_sigma / self.semimajor_sigma) @lazyproperty @use_detcat @as_scalar def covar_sigx2(self): r""" The ``(0, 0)`` element of the `covariance` matrix, representing :math:`\sigma_x^2`, in units of pixel**2. """ return self._covariance[:, 0, 0] * u.pix**2 @lazyproperty @use_detcat @as_scalar def covar_sigy2(self): r""" The ``(1, 1)`` element of the `covariance` matrix, representing :math:`\sigma_y^2`, in units of pixel**2. """ return self._covariance[:, 1, 1] * u.pix**2 @lazyproperty @use_detcat @as_scalar def covar_sigxy(self): r""" The ``(0, 1)`` and ``(1, 0)`` elements of the `covariance` matrix, representing :math:`\sigma_x \sigma_y`, in units of pixel**2. """ return self._covariance[:, 0, 1] * u.pix**2 @lazyproperty @use_detcat @as_scalar def cxx(self): r""" 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_sigma)**2 + (np.sin(self.orientation) / self.semiminor_sigma)**2) @lazyproperty @use_detcat @as_scalar def 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_sigma)**2 + (np.cos(self.orientation) / self.semiminor_sigma)**2) @lazyproperty @use_detcat @as_scalar def 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_sigma**2) - (1.0 / self.semiminor_sigma**2))) @lazyproperty @use_detcat @as_scalar def gini(self): r""" The `Gini coefficient `_ of the source. The Gini coefficient is calculated using the prescription from `Lotz et al. 2004 `_ as: .. math:: G = \frac{1}{\left | \bar{x} \right | n (n - 1)} \sum^{n}_{i} (2i - n - 1) \left | x_i \right | where :math:`\bar{x}` is the mean over pixel values :math:`x_i` within the source segment. If the sum of all pixel values is zero, the Gini coefficient is zero. The Gini coefficient is a way of measuring the inequality in a given set of values. In the context of galaxy morphology, it measures how the light of a galaxy image is distributed among its pixels. A Gini coefficient value of 0 corresponds to a galaxy image with the light evenly distributed over all pixels while a Gini coefficient value of 1 represents a galaxy image with all its light concentrated in just one pixel. """ return np.array([gini_func(arr) for arr in self._data_values]) @lazyproperty def _local_background_apertures(self): """ The `~photutils.aperture.RectangularAnnulus` aperture used to estimate the local background. """ if self.localbkg_width == 0: return self._null_objects apertures = [] for bbox_ in self._bbox: xpos = 0.5 * (bbox_.ixmin + bbox_.ixmax - 1) ypos = 0.5 * (bbox_.iymin + bbox_.iymax - 1) scale = 1.5 width_in = (bbox_.ixmax - bbox_.ixmin) * scale width_out = width_in + 2 * self.localbkg_width height_in = (bbox_.iymax - bbox_.iymin) * scale height_out = height_in + 2 * self.localbkg_width apertures.append(RectangularAnnulus((xpos, ypos), width_in, width_out, height_out, height_in, theta=0.0)) return apertures @lazyproperty @use_detcat @as_scalar def local_background_aperture(self): """ The `~photutils.aperture.RectangularAnnulus` aperture used to estimate the local background. """ return self._local_background_apertures @lazyproperty def _local_background(self): """ The local background value (per pixel) estimated using a rectangular annulus aperture around the source. Pixels are masked where the input ``mask`` is `True`, where the input ``data`` is non-finite, and within any non-zero pixel label in the segmentation image. This property is always an `~numpy.ndarray` without units. """ if self.localbkg_width == 0: local_bkgs = np.zeros(self.nlabels) else: sigma_clip = SigmaClip(sigma=3.0, cenfunc='median', maxiters=20) bkg_func = SExtractorBackground(sigma_clip) bkg_apers = self._local_background_apertures local_bkgs = [] for aperture in bkg_apers: aperture_mask = aperture.to_mask(method='center') slc_lg, slc_sm = aperture_mask.get_overlap_slices( self._data.shape) data_cutout = self._data[slc_lg].astype(float, copy=True) # all non-zero segment labels are masked segm_mask_cutout = self._segment_img.data[slc_lg].astype(bool) if self._mask is None: mask_cutout = None else: mask_cutout = self._mask[slc_lg] data_mask_cutout = self._make_cutout_data_mask(data_cutout, mask_cutout) data_mask_cutout |= segm_mask_cutout aperweight_cutout = aperture_mask.data[slc_sm] good_mask = (aperweight_cutout > 0) & ~data_mask_cutout data_cutout *= aperweight_cutout data_values = data_cutout[good_mask] # 1D array # check not enough unmasked pixels if len(data_values) < 10: # pragma: no cover local_bkgs.append(0.0) continue local_bkgs.append(bkg_func(data_values)) local_bkgs = np.array(local_bkgs) local_bkgs[self._all_masked] = np.nan return local_bkgs @lazyproperty @as_scalar def local_background(self): """ The local background value (per pixel) estimated using a rectangular annulus aperture around the source. """ bkg = self._local_background if self._data_unit is not None: bkg <<= self._data_unit return bkg def _make_aperture_data(self, label, xcentroid, ycentroid, aperture_bbox, local_background, make_error=True): """ Make cutouts of data, error, and mask arrays for aperture photometry (e.g., circular or Kron). Neighboring sources can be included, masked, or corrected based on the ``apermask_method`` keyword. """ # make cutouts of the data based on the aperture bbox slc_lg, slc_sm = aperture_bbox.get_overlap_slices(self._data.shape) if slc_lg is None: return (None,) * 5 data = self._data[slc_lg].astype(float) - local_background mask_cutout = None if self._mask is None else self._mask[slc_lg] data_mask = self._make_cutout_data_mask(data, mask_cutout) if make_error and self._error is not None: error = self._error[slc_lg] else: error = None # calculate cutout centroid position cutout_xycen = (xcentroid - max(0, aperture_bbox.ixmin), ycentroid - max(0, aperture_bbox.iymin)) # mask or correct neighboring sources if self.apermask_method != 'none': segment_img = self._segment_img.data[slc_lg] segm_mask = np.logical_and(segment_img != label, segment_img != 0) if self.apermask_method == 'mask': mask = data_mask | segm_mask else: mask = data_mask if self.apermask_method == 'correct': from photutils.segmentation.utils import _mask_to_mirrored_value data = _mask_to_mirrored_value(data, segm_mask, cutout_xycen, mask=mask) if error is not None: error = _mask_to_mirrored_value(error, segm_mask, cutout_xycen, mask=mask) return data, error, mask, cutout_xycen, slc_sm def _make_circular_apertures(self, radius): """ Make circular aperture for each source. The aperture for each source will be centered at its `centroid` position. If a ``detection_cat`` was input to `SourceCatalog`, it will be used for the source centroids. Parameters ---------- radius : float, 1D `~numpy.ndarray` The radius of the circle in pixels. Returns ------- result : list of `~photutils.aperture.CircularAperture` A list of `~photutils.aperture.CircularAperture` instances. The aperture will be `None` where the source `centroid` position is not finite or where the source is completely masked. """ radius = np.broadcast_to(radius, len(self._xcentroid)) if np.any(radius <= 0): raise ValueError('radius must be > 0') apertures = [] for (xcen, ycen, radius_, all_masked) in zip(self._xcentroid, self._ycentroid, radius, self._all_masked, strict=True): if all_masked or np.any(~np.isfinite((xcen, ycen, radius_))): apertures.append(None) continue apertures.append(CircularAperture((xcen, ycen), r=radius_)) return apertures @as_scalar def make_circular_apertures(self, radius): """ Make circular aperture for each source. The aperture for each source will be centered at its `centroid` position. If a ``detection_cat`` was input to `SourceCatalog`, then its `centroid` values will be used. Parameters ---------- radius : float The radius of the circle in pixels. Returns ------- result : `~photutils.aperture.CircularAperture` or \ list of `~photutils.aperture.CircularAperture` The circular aperture for each source. The aperture will be `None` where the source `centroid` position is not finite or where the source is completely masked. """ return self._make_circular_apertures(radius) @as_scalar def plot_circular_apertures(self, radius, ax=None, origin=(0, 0), **kwargs): """ Plot circular apertures for each source on a matplotlib `~matplotlib.axes.Axes` instance. The aperture for each source will be centered at its `centroid` position. If a ``detection_cat`` was input to `SourceCatalog`, then its `centroid` values will be used. An aperture will not be plotted for sources where the source `centroid` position is not finite or where the source is completely masked. Parameters ---------- radius : float The radius of the circle in pixels. ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : dict, optional Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- patch : `~matplotlib.patches.Patch` or \ list of `~matplotlib.patches.Patch` The matplotlib patch for each plotted aperture. The patches can be used, for example, when adding a plot legend. """ apertures = self._make_circular_apertures(radius) patches = [] for aperture in apertures: if aperture is not None: aperture.plot(ax=ax, origin=origin, **kwargs) patches.append(aperture._to_patch(origin=origin, **kwargs)) return patches def circular_photometry(self, radius, name=None, overwrite=False): """ Perform circular aperture photometry for each source. The circular aperture for each source will be centered at its `centroid` position. If a ``detection_cat`` was input to `SourceCatalog`, then its `centroid` values will be used. See the `SourceCatalog` ``apermask_method`` keyword for options to mask neighboring sources. Parameters ---------- radius : float The radius of the circle in pixels. name : str or `None`, optional The prefix name which will be used to define attribute names for the flux and flux error. The attribute names ``[name]_flux`` and ``[name]_fluxerr`` will store the photometry results. For example, these names can then be included in the `to_table` ``columns`` keyword list to output the results in the table. overwrite : bool, optional If True, overwrite the attribute ``name`` if it exists. Returns ------- flux, fluxerr : float or `~numpy.ndarray` of floats The aperture fluxes and flux errors. NaN will be returned where the aperture is `None` (e.g., where the source `centroid` position is not finite or the source is completely masked). """ if radius <= 0: raise ValueError('radius must be > 0') apertures = self._make_circular_apertures(radius) kwargs = self._apermask_kwargs['circ'] flux, fluxerr = self._aperture_photometry(apertures, desc='circular_photometry', **kwargs) if self._data_unit is not None: flux <<= self._data_unit fluxerr <<= self._data_unit if self.isscalar: flux = flux[0] fluxerr = fluxerr[0] if name is not None: flux_name = f'{name}_flux' fluxerr_name = f'{name}_fluxerr' self.add_extra_property(flux_name, flux, overwrite=overwrite) self.add_extra_property(fluxerr_name, fluxerr, overwrite=overwrite) return flux, fluxerr def _make_elliptical_apertures(self, scale=6.0): """ Return a list of elliptical apertures based on the scaled isophotal shape of the sources. If a ``detection_cat`` was input to `SourceCatalog`, then its source `centroid` and shape parameters will be used. If scale is zero (due to a minimum circular radius set in ``kron_params``) then a circular aperture will be returned with the minimum circular radius. Parameters ---------- scale : float or `~numpy.ndarray`, optional The scale factor to apply to the ellipse major and minor axes. The default value of 6.0 is roughly two times the isophotal extent of the source. A `~numpy.ndarray` input must be a 1D array of length ``nlabels``. Returns ------- result : list of `~photutils.aperture.EllipticalAperture` A list of `~photutils.aperture.EllipticalAperture` instances. The aperture will be `None` where the source `centroid` position or elliptical shape parameters are not finite or where the source is completely masked. """ xcen = self._xcentroid ycen = self._ycentroid major_size = self.semimajor_sigma.value * scale minor_size = self.semiminor_sigma.value * scale theta = self.orientation.to(u.radian).value if self.isscalar: major_size = (major_size,) minor_size = (minor_size,) theta = (theta,) aperture = [] for values in zip(xcen, ycen, major_size, minor_size, theta, self._all_masked, strict=True): if values[-1] or np.any(~np.isfinite(values[:-1])): aperture.append(None) continue # kron_radius = 0 -> scale = 0 -> major/minor_size = 0 if values[2] == 0 and values[3] == 0: aperture.append(CircularAperture((values[0], values[1]), r=self.kron_params[2])) continue (xcen_, ycen_, major_, minor_, theta_) = values[:-1] aperture.append(EllipticalAperture((xcen_, ycen_), major_, minor_, theta=theta_)) return aperture @lazyproperty @use_detcat def _measured_kron_radius(self): r""" The *unscaled* first-moment Kron radius, always as an array (without units). The returned value is the measured Kron radius without applying any minimum Kron or circular radius. """ apertures = self._make_elliptical_apertures(scale=6.0) cxx = self.cxx.value cxy = self.cxy.value cyy = self.cyy.value if self.isscalar: cxx = (cxx,) cxy = (cxy,) cyy = (cyy,) labels = self.labels if self.progress_bar: # pragma: no cover desc = 'kron_radius' labels = add_progress_bar(labels, desc=desc) kron_radius = [] for (label, aperture, cxx_, cxy_, cyy_) in zip(labels, apertures, cxx, cxy, cyy, strict=True): if aperture is None: kron_radius.append(np.nan) continue xcen, ycen = aperture.positions # use 'center' (whole pixels) to compute Kron radius aperture_mask = aperture.to_mask(method='center') # prepare cutouts of the data based on the aperture size # local background explicitly set to zero for SE agreement data, _, mask, xycen, slc_sm = self._make_aperture_data( label, xcen, ycen, aperture_mask.bbox, 0.0, make_error=False) xval = np.arange(data.shape[1]) - xycen[0] yval = np.arange(data.shape[0]) - xycen[1] xx, yy = np.meshgrid(xval, yval) rr = np.sqrt(cxx_ * xx**2 + cxy_ * xx * yy + cyy_ * yy**2) aperture_weights = aperture_mask.data[slc_sm] pixel_mask = (aperture_weights > 0) & ~mask # good pixels # ignore RuntimeWarning for invalid data values with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) flux_numer = np.sum((aperture_weights * data * rr)[pixel_mask]) flux_denom = np.sum((aperture_weights * data)[pixel_mask]) # set Kron radius to the minimum Kron radius if numerator or # denominator is negative if flux_numer <= 0 or flux_denom <= 0: kron_radius.append(self.kron_params[1]) continue kron_radius.append(flux_numer / flux_denom) return np.array(kron_radius) @as_scalar def _calc_kron_radius(self, kron_params): """ Calculate the *unscaled* first-moment Kron radius, applying any minimum Kron or circular radius to the measured Kron radius. Returned as a Quantity array or scalar (if self isscalar) with pixel units. """ kron_radius = self._measured_kron_radius.copy() # set minimum (unscaled) kron radius kron_radius[kron_radius < kron_params[1]] = kron_params[1] # check for minimum circular radius if len(kron_params) == 3: major_sigma = self.semimajor_sigma.value minor_sigma = self.semiminor_sigma.value circ_radius = (kron_params[0] * kron_radius * np.sqrt(major_sigma * minor_sigma)) kron_radius[circ_radius <= kron_params[2]] = 0.0 return kron_radius << u.pix @lazyproperty @use_detcat @as_scalar def kron_radius(self): r""" The *unscaled* first-moment Kron radius. The *unscaled* first-moment Kron radius is given by: .. math:: r_k = \frac{\sum_{i \in A} \ r_i I_i}{\sum_{i \in A} I_i} where :math:`I_i` are the data values and the sum is over pixels in an elliptical aperture whose axes are defined by six times the semimajor (`semimajor_sigma`) and semiminor axes (`semiminor_sigma`) at the calculated `orientation` (all properties derived from the central image moments of the source). :math:`r_i` is the elliptical "radius" to the pixel given by: .. math:: r_i^2 = cxx (x_i - \bar{x})^2 + cxy (x_i - \bar{x})(y_i - \bar{y}) + cyy (y_i - \bar{y})^2 where :math:`\bar{x}` and :math:`\bar{y}` represent the source `centroid` and the coefficients are based on image moments (`cxx`, `cxy`, and `cyy`). The `kron_radius` value is the unscaled moment value. The minimum unscaled radius can be set using the second element of the `SourceCatalog` ``kron_params`` keyword. If either the numerator or denominator above is less than or equal to 0, then the minimum unscaled Kron radius (``kron_params[1]``) will be used. The Kron aperture is calculated for each source using its shape parameters, `kron_radius`, and the ``kron_params`` scaling and minimum values input into `SourceCatalog`. The Kron aperture is used to compute the Kron photometry. If ``kron_params[0]`` * `kron_radius` * sqrt(`semimajor_sigma` * `semiminor_sigma`) is less than or equal to the minimum circular radius (``kron_params[2]``), then the Kron radius will be set to zero and the Kron aperture will be a circle with this minimum radius. If the source is completely masked, then ``np.nan`` will be returned for both the Kron radius and Kron flux (the Kron aperture will be `None`). If a ``detection_cat`` was input to `SourceCatalog`, then its ``kron_radius`` will be returned. See the `SourceCatalog` ``apermask_method`` keyword for options to mask neighboring sources. """ return self._calc_kron_radius(self.kron_params) def _make_kron_apertures(self, kron_params): """ Make Kron apertures for each source, always returned as a list. """ # NOTE: if kron_radius = NaN, scale = NaN and kron_aperture = None kron_radius = self._calc_kron_radius(kron_params) scale = kron_radius.value * kron_params[0] return self._make_elliptical_apertures(scale=scale) @lazyproperty @use_detcat @as_scalar def kron_aperture(self): r""" The elliptical (or circular) Kron aperture. The Kron aperture is calculated for each source using its shape parameters, `kron_radius`, and the ``kron_params`` scaling and minimum values input into `SourceCatalog`. The Kron aperture is used to compute the Kron photometry. If ``kron_params[0]`` * `kron_radius` * sqrt(`semimajor_sigma` * `semiminor_sigma`) is less than or equal to the minimum circular radius (``kron_params[2]``), then the Kron aperture will be a circle with this minimum radius. The aperture will be `None` where the source `centroid` position or elliptical shape parameters are not finite or where the source is completely masked. If a ``detection_cat`` was input to `SourceCatalog`, then its ``kron_aperture`` will be returned. """ return self._make_kron_apertures(self.kron_params) @as_scalar def make_kron_apertures(self, kron_params=None): """ Make Kron apertures for each source. The aperture for each source will be centered at its `centroid` position. If a ``detection_cat`` was input to `SourceCatalog`, then its `centroid` values will be used. Note that changing ``kron_params`` from the values input into `SourceCatalog` does not change the Kron apertures (`kron_aperture`) and photometry (`kron_flux` and `kron_fluxerr`) in the source catalog. This method should be used only to explore alternative ``kron_params`` with a detection image. Parameters ---------- kron_params : list of 2 or 3 floats or `None`, optional A list of parameters used to determine the Kron aperture. The first item is the scaling parameter of the unscaled Kron radius and the second item represents the minimum value for the unscaled Kron radius in pixels. The optional third item is the minimum circular radius in pixels. If ``kron_params[0]`` * `kron_radius` * sqrt(`semimajor_sigma` * `semiminor_sigma`) is less than or equal to this radius, then the Kron aperture will be a circle with this minimum radius. If `None`, then the ``kron_params`` input into `SourceCatalog` will be used (the apertures will be the same as those in `kron_aperture`). Returns ------- result : `~photutils.aperture.PixelAperture` \ or list of `~photutils.aperture.PixelAperture` The Kron apertures for each source. Each aperture will either be a `~photutils.aperture.EllipticalAperture`, `~photutils.aperture.CircularAperture`, or `None`. The aperture will be `None` where the source `centroid` position or elliptical shape parameters are not finite or where the source is completely masked. """ if kron_params is None: return self.kron_aperture return self._make_kron_apertures(kron_params) @as_scalar def plot_kron_apertures(self, kron_params=None, ax=None, origin=(0, 0), **kwargs): """ Plot Kron apertures for each source on a matplotlib `~matplotlib.axes.Axes` instance. The aperture for each source will be centered at its `centroid` position. If a ``detection_cat`` was input to `SourceCatalog`, then its `centroid` values will be used. An aperture will not be plotted for sources where the source `centroid` position or elliptical shape parameters are not finite or where the source is completely masked. Note that changing ``kron_params`` from the values input into `SourceCatalog` does not change the Kron apertures (`kron_aperture`) and photometry (`kron_flux` and `kron_fluxerr`) in the source catalog. This method should be used only to visualize/explore alternative ``kron_params`` with a detection image. Parameters ---------- kron_params : list of 2 or 3 floats or `None`, optional A list of parameters used to determine the Kron aperture. The first item is the scaling parameter of the unscaled Kron radius and the second item represents the minimum value for the unscaled Kron radius in pixels. The optional third item is the minimum circular radius in pixels. If ``kron_params[0]`` * `kron_radius` * sqrt(`semimajor_sigma` * `semiminor_sigma`) is less than or equal to this radius, then the Kron aperture will be a circle with this minimum radius. If `None`, then the ``kron_params`` input into `SourceCatalog` will be used (the apertures will be the same as those in `kron_aperture`). ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. **kwargs : dict, optional Any keyword arguments accepted by `matplotlib.patches.Patch`. Returns ------- patch : list of `~matplotlib.patches.Patch` A list of matplotlib patches for the plotted aperture. The patches can be used, for example, when adding a plot legend. """ if kron_params is None: apertures = self.kron_aperture if self.isscalar: apertures = (apertures,) else: apertures = self._make_kron_apertures(kron_params) patches = [] for aperture in apertures: if aperture is not None: aperture.plot(ax=ax, origin=origin, **kwargs) patches.append(aperture._to_patch(origin=origin, **kwargs)) return patches def _aperture_photometry(self, apertures, desc='', **kwargs): """ Perform aperture photometry on cutouts of the data and optional error arrays. The appropriate ``apermask_method`` is applied to the cutouts to handle neighboring sources. Parameters ---------- apertures : list of `PixelAperture` A list of the apertures. desc : str, optional The description displayed before the progress bar. **kwargs : dict, optional Additional keyword arguments passed to the aperture ``to_mask`` method. Returns ------- flux, fluxerr : 1D `~numpy.ndaray` The flux and flux error arrays. """ labels = self.labels if self.progress_bar: # pragma: no cover labels = add_progress_bar(labels, desc=desc) flux = [] fluxerr = [] for label, aperture, bkg in zip(labels, apertures, self._local_background, strict=True): # return NaN for completely masked sources or sources where # the centroid is not finite if aperture is None: flux.append(np.nan) fluxerr.append(np.nan) continue xcen, ycen = aperture.positions aperture_mask = aperture.to_mask(**kwargs) # prepare cutouts of the data based on the aperture size data, error, mask, _, slc_sm = self._make_aperture_data( label, xcen, ycen, aperture_mask.bbox, bkg) aperture_weights = aperture_mask.data[slc_sm] pixel_mask = (aperture_weights > 0) & ~mask # good pixels # ignore RuntimeWarning for invalid data or error values with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) values = (aperture_weights * data)[pixel_mask] flux_ = np.nan if values.shape == (0,) else np.sum(values) flux.append(flux_) if error is None: fluxerr_ = np.nan else: values = (aperture_weights * error**2)[pixel_mask] if values.shape == (0,): fluxerr_ = np.nan else: fluxerr_ = np.sqrt(np.sum(values)) fluxerr.append(fluxerr_) flux = np.array(flux) fluxerr = np.array(fluxerr) return flux, fluxerr def _calc_kron_photometry(self, kron_params=None): """ Calculate the flux and flux error in the Kron aperture (without units). See the `SourceCatalog` ``apermask_method`` keyword for options to mask neighboring sources. If the Kron aperture is `None`, then ``np.nan`` will be returned. If ``detection_cat`` is input, then its `centroid` values will be used. Returns ------- kron_flux, kron_fluxerr : tuple of `~numpy.ndarray` The Kron flux and flux error. """ if kron_params is None: kron_aperture = self.kron_aperture if self.isscalar: kron_aperture = (kron_aperture,) else: kron_params = self._validate_kron_params(kron_params) kron_aperture = self._make_kron_apertures(kron_params) kwargs = self._apermask_kwargs['kron'] flux, fluxerr = self._aperture_photometry(kron_aperture, desc='kron_photometry', **kwargs) return flux, fluxerr def kron_photometry(self, kron_params, name=None, overwrite=False): """ Perform photometry for each source using an elliptical Kron aperture. This method can be used to calculate the Kron photometry using alternate ``kron_params`` (e.g., different scalings of the Kron radius). See the `SourceCatalog` ``apermask_method`` keyword for options to mask neighboring sources. Parameters ---------- kron_params : list of 2 or 3 floats, optional A list of parameters used to determine the Kron aperture. The first item is the scaling parameter of the unscaled Kron radius and the second item represents the minimum value for the unscaled Kron radius in pixels. The optional third item is the minimum circular radius in pixels. If ``kron_params[0]`` * `kron_radius` * sqrt(`semimajor_sigma` * `semiminor_sigma`) is less than or equal to this radius, then the Kron aperture will be a circle with this minimum radius. name : str or `None`, optional The prefix name which will be used to define attribute names for the Kron flux and flux error. The attribute names ``[name]_flux`` and ``[name]_fluxerr`` will store the photometry results. For example, these names can then be included in the `to_table` ``columns`` keyword list to output the results in the table. overwrite : bool, optional If True, overwrite the attribute ``name`` if it exists. Returns ------- flux, fluxerr : float or `~numpy.ndarray` of floats The aperture fluxes and flux errors. NaN will be returned where the aperture is `None` (e.g., where the source `centroid` position or elliptical shape parameters are not finite or where the source is completely masked). """ kron_flux, kron_fluxerr = self._calc_kron_photometry(kron_params) if self._data_unit is not None: kron_flux <<= self._data_unit kron_fluxerr <<= self._data_unit if self.isscalar: kron_flux = kron_flux[0] kron_fluxerr = kron_fluxerr[0] if name is not None: flux_name = f'{name}_flux' fluxerr_name = f'{name}_fluxerr' self.add_extra_property(flux_name, kron_flux, overwrite=overwrite) self.add_extra_property(fluxerr_name, kron_fluxerr, overwrite=overwrite) return kron_flux, kron_fluxerr @lazyproperty def _kron_photometry(self): """ The flux and flux error in the Kron aperture (without units). See the `SourceCatalog` ``apermask_method`` keyword for options to mask neighboring sources. If the Kron aperture is `None`, then ``np.nan`` will be returned. This will occur where the source `centroid` position or elliptical shape parameters are not finite or where the source is completely masked. """ return np.transpose(self._calc_kron_photometry(kron_params=None)) @lazyproperty @as_scalar def kron_flux(self): """ The flux in the Kron aperture. See the `SourceCatalog` ``apermask_method`` keyword for options to mask neighboring sources. If the Kron aperture is `None`, then ``np.nan`` will be returned. This will occur where the source `centroid` position or elliptical shape parameters are not finite or where the source is completely masked. """ kron_flux = self._kron_photometry[:, 0] if self._data_unit is not None: kron_flux <<= self._data_unit return kron_flux @lazyproperty @as_scalar def kron_fluxerr(self): """ The flux error in the Kron aperture. See the `SourceCatalog` ``apermask_method`` keyword for options to mask neighboring sources. If the Kron aperture is `None`, then ``np.nan`` will be returned. This will occur where the source `centroid` position or elliptical shape parameters are not finite or where the source is completely masked. """ kron_fluxerr = self._kron_photometry[:, 1] if self._data_unit is not None: kron_fluxerr <<= self._data_unit return kron_fluxerr @lazyproperty @use_detcat def _max_circular_kron_radius(self): """ The maximum circular Kron radius used as the upper limit of fluxfrac_radius. """ semimajor_sig = self.semimajor_sigma.value kron_radius = self.kron_radius.value radius = semimajor_sig * kron_radius * self.kron_params[0] mask = radius == 0 if np.any(mask): radius[mask] = self.kron_params[2] if self.isscalar: radius = np.array([radius]) return radius @staticmethod def _fluxfrac_radius_fcn(radius, data, mask, aperture, normflux, kwargs): """ Function whose root is found to compute the fluxfrac_radius. """ aperture.r = radius flux, _ = aperture.do_photometry(data, mask=mask, **kwargs) return 1.0 - (flux[0] / normflux) @lazyproperty @use_detcat def _fluxfrac_optimizer_args(self): kron_flux = self._kron_photometry[:, 0] # unitless max_radius = self._max_circular_kron_radius kwargs = self._apermask_kwargs['fluxfrac'] labels = self.labels if self.progress_bar: # pragma: no cover desc = 'fluxfrac_radius prep' labels = add_progress_bar(labels, desc=desc) args = [] for label, xcen, ycen, kronflux, bkg, max_radius_ in zip( labels, self._xcentroid, self._ycentroid, kron_flux, self._local_background, max_radius, strict=True): if (np.any(~np.isfinite((xcen, ycen, kronflux, max_radius_))) or kronflux == 0): args.append(None) continue aperture = CircularAperture((xcen, ycen), r=max_radius_) aperture_mask = aperture.to_mask(**kwargs) # prepare cutouts of the data based on the maximum aperture size data, _, mask, xycen, _ = self._make_aperture_data( label, xcen, ycen, aperture_mask.bbox, bkg, make_error=False) aperture.positions = xycen args.append([data, mask, aperture, kronflux, kwargs, max_radius_]) return args @as_scalar def fluxfrac_radius(self, fluxfrac, name=None, overwrite=False): """ Calculate the circular radius that encloses the specified fraction of the Kron flux. To estimate the half-light radius, use ``fluxfrac = 0.5``. Parameters ---------- fluxfrac : float The fraction of the Kron flux at which to find the circular radius. name : str or `None`, optional The attribute name which will be assigned to the value of the output array. For example, this name can then be included in the `to_table` ``columns`` keyword list to output the results in the table. overwrite : bool, optional If True, overwrite the attribute ``name`` if it exists. Returns ------- radius : 1D `~numpy.ndarray` The circular radius that encloses the specified fraction of the Kron flux. NaN is returned where no solution was found or where the Kron flux is zero or non-finite. """ if fluxfrac <= 0 or fluxfrac > 1: raise ValueError('fluxfrac must be > 0 and <= 1') args = self._fluxfrac_optimizer_args if self.progress_bar: # pragma: no cover desc = 'fluxfrac_radius' args = add_progress_bar(args, desc=desc) radius = [] for fluxfrac_args in args: if fluxfrac_args is None: radius.append(np.nan) continue max_radius = fluxfrac_args[-1] args = fluxfrac_args[:-1] args[3] *= fluxfrac args = tuple(args) # Try to find the root of self._fluxfrac_radius_fnc, which # is bracketed by a min and max radius. A ValueError is # raised if the bracket points do not have different signs, # indicating no solution or multiple solutions (e.g., a # multi-valued function). This can happen when at some # radius, flux starts decreasing with increasing radius (due # to negative data values), resulting in multiple possible # solutions. If no solution is found, we iteratively # decrease the max radius to narrow the bracket range until # the root is found. If max radius drops below the min # radius (0.1), then no solution is possible and NaN will be # returned as the result. found = False min_radius = 0.1 max_radius_delta = 1.0 while max_radius > min_radius and found is False: try: bracket = [min_radius, max_radius] result = root_scalar(self._fluxfrac_radius_fcn, args=args, bracket=bracket, method='brentq') result = result.root found = True except ValueError: # pragma: no cover # ValueError is raised if the bracket points do not # have different signs max_radius -= max_radius_delta # no solution found between min_radius and max_radius if found is False: result = np.nan radius.append(result) result = np.array(radius) << u.pix if name is not None: self.add_extra_property(name, result, overwrite=overwrite) return result @as_scalar def make_cutouts(self, shape, mode='partial', fill_value=np.nan): """ Make cutout arrays for each source. The cutout for each source will be centered at its `centroid` position. If a ``detection_cat`` was input to `SourceCatalog`, then its `centroid` values will be used. Parameters ---------- shape : 2-tuple The cutout shape along each axis in ``(ny, nx)`` order. mode : {'partial', 'trim'}, optional The mode used for extracting the cutout array. In ``'partial'`` mode, positions in the cutout array that do not overlap with the large array will be filled with ``fill_value``. In ``'trim'`` mode, only the overlapping elements are returned, thus the resulting small array may be smaller than the requested ``shape``. fill_value : number, optional If ``mode='partial'``, the value to fill pixels in the extracted cutout array that do not overlap with the input ``array_large``. ``fill_value`` will be changed to have the same ``dtype`` as the ``array_large`` array, with one exception. If ``array_large`` has integer type and ``fill_value`` is ``np.nan``, then a `ValueError` will be raised. Returns ------- cutouts : `~photutils.utils.CutoutImage` \ or list of `~photutils.utils.CutoutImage` The `~photutils.utils.CutoutImage` for each source. The cutout will be `None` where the source `centroid` position is not finite or where the source is completely masked. """ if mode not in ('partial', 'trim'): raise ValueError('mode must be "partial" or "trim"') cutouts = [] for (xcen, ycen, all_masked) in zip(self._xcentroid, self._ycentroid, self._all_masked, strict=True): if all_masked or np.any(~np.isfinite((xcen, ycen))): cutouts.append(None) continue cutouts.append(CutoutImage(self._data, (ycen, xcen), shape, mode=mode, fill_value=fill_value)) return cutouts ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/segmentation/core.py0000644000175100001660000017577014755160622021234 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides classes for a segmentation image and a single segment within a segmentation image. """ import inspect import warnings from copy import copy, deepcopy import numpy as np from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from scipy.ndimage import find_objects, grey_dilation from scipy.signal import fftconvolve from photutils.aperture import BoundingBox from photutils.aperture.converters import _shapely_polygon_to_region from photutils.utils._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'] class SegmentationImage: """ Class for a segmentation image. Parameters ---------- data : 2D int `~numpy.ndarray` A 2D segmentation array where source regions are labeled by different positive integer values. A value of zero is reserved for the background. The segmentation image must have integer type. Notes ----- The `SegmentationImage` instance may be sliced, but note that the sliced `SegmentationImage` data array will be a view into the original `SegmentationImage` array (this is the same behavior as `~numpy.ndarray`). Explicitly use the :meth:`SegmentationImage.copy` method to create a copy of the sliced `SegmentationImage`. """ def __init__(self, data): if not isinstance(data, np.ndarray): raise TypeError('Input data must be a numpy array') self.data = data self._deblend_label_map = {} # set by source deblender def __str__(self): cls_name = f'<{self.__class__.__module__}.{self.__class__.__name__}>' params = ['shape', 'nlabels'] 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__() def __getitem__(self, key): """ Slice the segmentation image, returning a new SegmentationImage object. """ if (isinstance(key, tuple) and len(key) == 2 and all(isinstance(key[i], slice) and (key[i].start != key[i].stop) for i in (0, 1))): return SegmentationImage(self.data[key]) raise TypeError(f'{key!r} is not a valid 2D slice object') def __array__(self): """ Array representation of the segmentation array (e.g., for matplotlib). """ return self._data @staticmethod def _get_labels(data): """ Return a sorted array of the non-zero labels in the segmentation image. Parameters ---------- data : array_like (int) A segmentation array where source regions are labeled by different positive integer values. A value of zero is reserved for the background. Returns ------- result : `~numpy.ndarray` An array of non-zero label numbers. Notes ----- This is a static method so it can be used in :meth:`remove_masked_labels` on a masked version of the segmentation array. """ # np.unique preserves dtype and also sorts elements return np.unique(data[data != 0]) @lazyproperty def segments(self): """ A list of `Segment` objects. The list starts with the *non-zero* label. The returned list has a length equal to the number of labels and matches the order of the ``labels`` attribute. """ segments = [] if HAS_RASTERIO and HAS_SHAPELY: for label, slc, bbox, area, polygon in zip(self.labels, self.slices, self.bbox, self.areas, self.polygons, strict=True): segments.append(Segment(self.data, label, slc, bbox, area, polygon=polygon)) else: for label, slc, bbox, area in zip(self.labels, self.slices, self.bbox, self.areas, strict=True): segments.append(Segment(self.data, label, slc, bbox, area)) return segments @lazyproperty def deblended_labels(self): """ A sorted 1D array of deblended label numbers. The list will be empty if deblending has not been performed or if no sources were deblended. """ if len(self._deblend_label_map) == 0: return np.array([], dtype=self._data.dtype) return np.sort(np.concatenate(list(self._deblend_label_map.values()))) @lazyproperty def deblended_labels_map(self): """ A dictionary mapping deblended label numbers to the original parent label numbers. The keys are the deblended label numbers and the values are the original parent label numbers. Only deblended sources are included in the dictionary. The dictionary will be empty if deblending has not been performed or if no sources were deblended. """ inverse_map = {} for key, values in self._deblend_label_map.items(): for value in values: inverse_map[value] = key return inverse_map @lazyproperty def deblended_labels_inverse_map(self): """ A dictionary mapping the original parent label numbers to the deblended label numbers. The keys are the original parent label numbers and the values are the deblended label numbers. Only deblended sources are included in the dictionary. The dictionary will be empty if deblending has not been performed or if no sources were deblended. """ return self._deblend_label_map @property def data(self): """ The segmentation array. """ return self._data @property def _lazyproperties(self): """ A list of all class lazyproperties (even in superclasses). """ def islazyproperty(obj): return isinstance(obj, lazyproperty) return [i[0] for i in inspect.getmembers(self.__class__, predicate=islazyproperty)] def _reset_lazyproperties(self): for key in self._lazyproperties: self.__dict__.pop(key, None) @data.setter def data(self, value): if not np.issubdtype(value.dtype, np.integer): raise TypeError('data must be have integer type') labels = self._get_labels(value) # array([]) if value all zeros if labels.shape != (0,) and np.min(labels) < 0: raise ValueError('The segmentation image cannot contain ' 'negative integers.') if '_data' in self.__dict__: # reset cached properties when data is reassigned, but not on init self._reset_lazyproperties() self._data = value # pylint: disable=attribute-defined-outside-init self.__dict__['labels'] = labels self.__dict__['_deblend_label_map'] = {} # reset deblended labels @lazyproperty def data_ma(self): """ A `~numpy.ma.MaskedArray` version of the segmentation array where the background (label = 0) has been masked. """ return np.ma.masked_where(self.data == 0, self.data) @lazyproperty def shape(self): """ The shape of the segmentation array. """ return self._data.shape @lazyproperty def _ndim(self): """ The number of array dimensions of the segmentation array. """ return self._data.ndim @lazyproperty def labels(self): """ The sorted non-zero labels in the segmentation array. """ if '_raw_slices' in self.__dict__: labels_all = np.arange(len(self._raw_slices)) + 1 labels = [] # if a label is missing, raw_slices will be None instead of a slice for label, slc in zip(labels_all, self._raw_slices, strict=True): if slc is not None: labels.append(label) return np.array(labels, dtype=self._data.dtype) return self._get_labels(self.data) @lazyproperty def nlabels(self): """ The number of non-zero labels in the segmentation array. """ return len(self.labels) @lazyproperty def max_label(self): """ The maximum label in the segmentation array. """ if self.nlabels == 0: return 0 return np.max(self.labels) def get_index(self, label): """ Find the index of the input ``label``. Parameters ---------- label : int The label number to find. Returns ------- index : int The array index. Raises ------ ValueError If ``label`` is invalid. """ self.check_labels(label) # self.labels is always sorted return np.searchsorted(self.labels, label) def get_indices(self, labels): """ Find the indices of the input ``labels``. Parameters ---------- labels : int, array_like (1D, int) The label numbers(s) to find. Returns ------- indices : int `~numpy.ndarray` An integer array of indices with the same shape as ``labels``. If ``labels`` is a scalar, then the returned index will also be a scalar. Raises ------ ValueError If any input ``labels`` are invalid. """ self.check_labels(labels) # self.labels is always sorted return np.searchsorted(self.labels, labels) @lazyproperty def _raw_slices(self): return find_objects(self.data) @lazyproperty def slices(self): """ A list of tuples, where each tuple contains two slices representing the minimal box that contains the labeled region. The list starts with the *non-zero* label. The returned list has a length equal to the number of labels and matches the order of the ``labels`` attribute. """ return [slc for slc in self._raw_slices if slc is not None] @lazyproperty def bbox(self): """ A list of `~photutils.aperture.BoundingBox` of the minimal bounding boxes containing the labeled regions. """ if self._ndim != 2: raise ValueError('The "bbox" attribute requires a 2D ' 'segmentation image.') return [BoundingBox(ixmin=slc[1].start, ixmax=slc[1].stop, iymin=slc[0].start, iymax=slc[0].stop) for slc in self.slices] @lazyproperty def background_area(self): """ The area (in pixel**2) of the background (label=0) region. """ return self._data.size - np.count_nonzero(self._data) @lazyproperty def areas(self): """ A 1D array of areas (in pixel**2) of the non-zero labeled regions. The `~numpy.ndarray` starts with the *non-zero* label. The returned array has a length equal to the number of labels and matches the order of the ``labels`` attribute. """ areas = [] for label, slices in zip(self.labels, self.slices, strict=True): areas.append(np.count_nonzero(self._data[slices] == label)) return np.array(areas) def get_area(self, label): """ The area (in pixel**2) of the region for the input label. Parameters ---------- label : int The label whose area to return. Label must be non-zero. Returns ------- area : float The area of the labeled region. """ return self.get_areas(label)[0] def get_areas(self, labels): """ The areas (in pixel**2) of the regions for the input labels. Parameters ---------- labels : int, 1D array_like (int) The label(s) for which to return areas. Label must be non-zero. Returns ------- areas : `~numpy.ndarray` The areas of the labeled regions. """ idx = self.get_indices(np.atleast_1d(labels)) return self.areas[idx] @lazyproperty def is_consecutive(self): """ Boolean value indicating whether or not the non-zero labels in the segmentation array are consecutive and start from 1. """ if self.nlabels == 0: return False return ((self.labels[-1] - self.labels[0] + 1) == self.nlabels and self.labels[0] == 1) @lazyproperty def missing_labels(self): """ A 1D `~numpy.ndarray` of the sorted non-zero labels that are missing in the consecutive sequence from one to the maximum label number. """ return np.array(sorted(set(range(self.max_label + 1)) .difference(np.insert(self.labels, 0, 0)))) 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 for positive label numbers idx = np.where(labels <= 0)[0] if idx.size > 0: bad_labels.update(labels[idx]) # check if label is in the segmentation array bad_labels.update(np.setdiff1d(labels, self.labels)) if bad_labels: # bad_labels is a set if len(bad_labels) == 1: raise ValueError(f'label {bad_labels} is invalid') raise ValueError(f'labels {bad_labels} are invalid') def _make_cmap(self, ncolors, background_color='#000000ff', seed=None): """ Define a matplotlib colormap consisting of (random) muted colors. This is useful for plotting the segmentation array. Parameters ---------- ncolors : int The number of the colors in the colormap. background_color : Matplotlib color, optional The color of the first color in the colormap. The color may be specified using any of the `Matplotlib color formats `_. This color will be used as the background color (label = 0) when plotting the segmentation image. The default color is black with alpha=1.0 ('#000000ff'). seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Separate function calls with the same ``seed`` will generate the same colormap. Returns ------- cmap : `matplotlib.colors.ListedColormap` The matplotlib colormap with colors in RGBA format. """ if self.nlabels == 0: return None from matplotlib import colors cmap = make_random_cmap(ncolors, seed=seed) if background_color is not None: cmap.colors[0] = colors.to_rgba(background_color) return cmap def make_cmap(self, background_color='#000000ff', seed=None): """ Define a matplotlib colormap consisting of (random) muted colors. This is useful for plotting the segmentation array. Parameters ---------- background_color : Matplotlib color, optional The color of the first color in the colormap. The color may be specified using any of the `Matplotlib color formats `_. This color will be used as the background color (label = 0) when plotting the segmentation image. The default color is black with alpha=1.0 ('#000000ff'). seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Separate function calls with the same ``seed`` will generate the same colormap. Returns ------- cmap : `matplotlib.colors.ListedColormap` The matplotlib colormap with colors in RGBA format. """ return self._make_cmap(self.max_label + 1, background_color=background_color, seed=seed) def reset_cmap(self, seed=None): """ Reset the colormap (`cmap` attribute) to a new random colormap. Parameters ---------- seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Separate function calls with the same ``seed`` will generate the same colormap. """ self.cmap = self.make_cmap(background_color='#000000ff', seed=seed) @lazyproperty def cmap(self): """ A matplotlib colormap consisting of (random) muted colors. This is useful for plotting the segmentation array. """ return self.make_cmap(background_color='#000000ff', seed=0) def _update_deblend_label_map(self, relabel_map): """ Update the deblended label map based on a relabel map. Parameters ---------- relabel_map : `~numpy.ndarray` An array mapping the original label numbers to the new label numbers. """ # child_labels are the deblended labels for parent_label, child_labels in self._deblend_label_map.items(): self._deblend_label_map[parent_label] = relabel_map[child_labels] def reassign_label(self, label, new_label, relabel=False): """ Reassign a label number to a new number. If ``new_label`` is already present in the segmentation array, then it will be combined with the input ``label`` number. Note that this can result in a label that is no longer pixel connected. Parameters ---------- label : int The label number to reassign. new_label : int The newly assigned label number. relabel : bool, optional If `True`, then the segmentation array will be relabeled such that the labels are in consecutive order starting from 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.reassign_label(label=1, new_label=2) >>> segm.data array([[2, 2, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.reassign_label(label=1, new_label=4) >>> segm.data array([[4, 4, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.reassign_label(label=1, new_label=4, relabel=True) >>> segm.data array([[2, 2, 0, 0, 2, 2], [0, 0, 0, 0, 0, 2], [0, 0, 1, 1, 0, 0], [4, 0, 0, 0, 0, 3], [4, 4, 0, 3, 3, 3], [4, 4, 0, 0, 3, 3]]) """ self.reassign_labels(label, new_label, relabel=relabel) def reassign_labels(self, labels, new_label, relabel=False): """ Reassign one or more label numbers. Multiple input ``labels`` will all be reassigned to the same ``new_label`` number. If ``new_label`` is already present in the segmentation array, then it will be combined with the input ``labels``. Note that both of these can result in a label that is no longer pixel connected. Parameters ---------- labels : int, array_like (1D, int) The label numbers(s) to reassign. new_label : int The reassigned label number. relabel : bool, optional If `True`, then the segmentation array will be relabeled such that the labels are in consecutive order starting from 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.reassign_labels(labels=[1, 7], new_label=2) >>> segm.data array([[2, 2, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [2, 0, 0, 0, 0, 5], [2, 2, 0, 5, 5, 5], [2, 2, 0, 0, 5, 5]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.reassign_labels(labels=[1, 7], new_label=4) >>> segm.data array([[4, 4, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [4, 0, 0, 0, 0, 5], [4, 4, 0, 5, 5, 5], [4, 4, 0, 0, 5, 5]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.reassign_labels(labels=[1, 7], new_label=2, relabel=True) >>> segm.data array([[1, 1, 0, 0, 3, 3], [0, 0, 0, 0, 0, 3], [0, 0, 2, 2, 0, 0], [1, 0, 0, 0, 0, 4], [1, 1, 0, 4, 4, 4], [1, 1, 0, 0, 4, 4]]) """ self.check_labels(labels) labels = np.atleast_1d(labels) if labels.size == 0: return dtype = self.data.dtype # keep the original dtype relabel_map = np.zeros(self.max_label + 1, dtype=dtype) relabel_map[self.labels] = self.labels relabel_map[labels] = new_label # reassign labels if relabel: labels = np.unique(relabel_map[relabel_map != 0]) if len(labels) != 0: map2 = np.zeros(max(labels) + 1, dtype=dtype) map2[labels] = np.arange(len(labels), dtype=dtype) + 1 relabel_map = map2[relabel_map] data_new = relabel_map[self.data] self._reset_lazyproperties() # reset all cached properties self._data = data_new # use _data to avoid validation self._update_deblend_label_map(relabel_map) def relabel_consecutive(self, start_label=1): """ Reassign the label numbers consecutively starting from a given label number. Parameters ---------- start_label : int, optional The starting label number, which should be a strictly positive integer. The default is 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.relabel_consecutive() >>> segm.data array([[1, 1, 0, 0, 3, 3], [0, 0, 0, 0, 0, 3], [0, 0, 2, 2, 0, 0], [5, 0, 0, 0, 0, 4], [5, 5, 0, 4, 4, 4], [5, 5, 0, 0, 4, 4]]) """ if self.nlabels == 0: warnings.warn('Cannot relabel a segmentation image of all zeros', AstropyUserWarning) return if start_label <= 0: raise ValueError('start_label must be > 0.') if ((self.labels[0] == start_label) and (self.labels[-1] - self.labels[0] + 1) == self.nlabels): return old_slices = self.__dict__.get('slices', None) dtype = self.data.dtype # keep the original dtype new_labels = np.arange(self.nlabels, dtype=dtype) + start_label new_label_map = np.zeros(self.max_label + 1, dtype=dtype) new_label_map[self.labels] = new_labels data_new = new_label_map[self.data] self._reset_lazyproperties() # reset all cached properties self._data = data_new # use _data to avoid validation self.__dict__['labels'] = new_labels if old_slices is not None: self.__dict__['slices'] = old_slices # slice order is unchanged self._update_deblend_label_map(new_label_map) def keep_label(self, label, relabel=False): """ Keep only the specified label. Parameters ---------- label : int The label number to keep. relabel : bool, optional If `True`, then the single segment will be assigned a label value of 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.keep_label(label=3) >>> segm.data array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 3, 3, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.keep_label(label=3, relabel=True) >>> segm.data array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 1, 1, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0]]) """ self.keep_labels(label, relabel=relabel) def keep_labels(self, labels, relabel=False): """ Keep only the specified labels. Parameters ---------- labels : int, array_like (1D, int) The label number(s) to keep. relabel : bool, optional If `True`, then the segmentation array will be relabeled such that the labels are in consecutive order starting from 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.keep_labels(labels=[5, 3]) >>> segm.data array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 3, 3, 0, 0], [0, 0, 0, 0, 0, 5], [0, 0, 0, 5, 5, 5], [0, 0, 0, 0, 5, 5]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.keep_labels(labels=[5, 3], relabel=True) >>> segm.data array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 1, 1, 0, 0], [0, 0, 0, 0, 0, 2], [0, 0, 0, 2, 2, 2], [0, 0, 0, 0, 2, 2]]) """ self.check_labels(labels) labels = np.atleast_1d(labels) labels_tmp = list(set(self.labels) - set(labels)) self.remove_labels(labels_tmp, relabel=relabel) def remove_label(self, label, relabel=False): """ Remove the label number. The removed label is assigned a value of zero (i.e., background). Parameters ---------- label : int The label number to remove. relabel : bool, optional If `True`, then the segmentation array will be relabeled such that the labels are in consecutive order starting from 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.remove_label(label=5) >>> segm.data array([[1, 1, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 0], [7, 7, 0, 0, 0, 0], [7, 7, 0, 0, 0, 0]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.remove_label(label=5, relabel=True) >>> segm.data array([[1, 1, 0, 0, 3, 3], [0, 0, 0, 0, 0, 3], [0, 0, 2, 2, 0, 0], [4, 0, 0, 0, 0, 0], [4, 4, 0, 0, 0, 0], [4, 4, 0, 0, 0, 0]]) """ self.remove_labels(label, relabel=relabel) def remove_labels(self, labels, relabel=False): """ Remove one or more labels. Removed labels are assigned a value of zero (i.e., background). Parameters ---------- labels : int, array_like (1D, int) The label number(s) to remove. relabel : bool, optional If `True`, then the segmentation array will be relabeled such that the labels are in consecutive order starting from 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.remove_labels(labels=[5, 3]) >>> segm.data array([[1, 1, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 0, 0, 0, 0], [7, 0, 0, 0, 0, 0], [7, 7, 0, 0, 0, 0], [7, 7, 0, 0, 0, 0]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.remove_labels(labels=[5, 3], relabel=True) >>> segm.data array([[1, 1, 0, 0, 2, 2], [0, 0, 0, 0, 0, 2], [0, 0, 0, 0, 0, 0], [3, 0, 0, 0, 0, 0], [3, 3, 0, 0, 0, 0], [3, 3, 0, 0, 0, 0]]) """ self.check_labels(labels) self.reassign_labels(labels, new_label=0, relabel=relabel) def remove_border_labels(self, border_width, partial_overlap=True, relabel=False): """ Remove labeled segments near the array border. Labels within the defined border region will be removed. Parameters ---------- border_width : int The width of the border region in pixels. partial_overlap : bool, optional If this is set to `True` (the default), a segment that partially extends into the border region will be removed. Segments that are completely within the border region are always removed. relabel : bool, optional If `True`, then the segmentation array will be relabeled such that the labels are in consecutive order starting from 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.remove_border_labels(border_width=1) >>> segm.data array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 3, 3, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.remove_border_labels(border_width=1, ... partial_overlap=False) >>> segm.data array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) """ if border_width >= min(self.shape) / 2: raise ValueError('border_width must be smaller than half the ' 'array size in any dimension') border_mask = np.zeros(self.shape, dtype=bool) for i in range(border_mask.ndim): border_mask = border_mask.swapaxes(0, i) border_mask[:border_width] = True border_mask[-border_width:] = True border_mask = border_mask.swapaxes(0, i) self.remove_masked_labels(border_mask, partial_overlap=partial_overlap, relabel=relabel) def remove_masked_labels(self, mask, partial_overlap=True, relabel=False): """ Remove labeled segments located within a masked region. Parameters ---------- mask : array_like (bool) A boolean mask, with the same shape as the segmentation array, where `True` values indicate masked pixels. partial_overlap : bool, optional If this is set to `True` (default), a segment that partially extends into a masked region will also be removed. Segments that are completely within a masked region are always removed. relabel : bool, optional If `True`, then the segmentation array will be relabeled such that the labels are in consecutive order starting from 1. Examples -------- >>> from photutils.segmentation import SegmentationImage >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> mask = np.zeros(segm.data.shape, dtype=bool) >>> mask[0, :] = True # mask the first row >>> segm.remove_masked_labels(mask) >>> segm.data array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) >>> data = np.array([[1, 1, 0, 0, 4, 4], ... [0, 0, 0, 0, 0, 4], ... [0, 0, 3, 3, 0, 0], ... [7, 0, 0, 0, 0, 5], ... [7, 7, 0, 5, 5, 5], ... [7, 7, 0, 0, 5, 5]]) >>> segm = SegmentationImage(data) >>> segm.remove_masked_labels(mask, partial_overlap=False) >>> segm.data array([[0, 0, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) """ if mask.shape != self.shape: raise ValueError('mask must have the same shape as the ' 'segmentation array') remove_labels = self._get_labels(self.data[mask]) if not partial_overlap: interior_labels = self._get_labels(self.data[~mask]) remove_labels = list(set(remove_labels) - set(interior_labels)) self.remove_labels(remove_labels, relabel=relabel) def make_source_mask(self, *, size=None, footprint=None): """ Make a source mask from the segmentation image. Use the ``size`` or ``footprint`` keyword to perform binary dilation on the segmentation image mask. Parameters ---------- size : int or tuple of int, optional The size along each axis of the rectangular footprint used for the source dilation. If ``size`` is a scalar, then a square footprint of ``size`` will be used. If ``size`` has two elements, they must be in ``(ny, nx)`` order. ``size`` should have odd values for each axis. To perform source dilation, either ``size`` or ``footprint`` must be defined. If they are both defined, then ``footprint`` overrides ``size``. footprint : 2D `~numpy.ndarray`, optional The local footprint used for the source dilation. Non-zero elements are considered `True`. ``size=(n, m)`` is equivalent to ``footprint=np.ones((n, m))``. To perform source dilation, either ``size`` or ``footprint`` must be defined. If they are both defined, then ``footprint`` overrides ``size``. Returns ------- mask : 2D bool `~numpy.ndarray` A 2D boolean image containing the source mask. Notes ----- When performing source dilation, using a square footprint will be much faster than using other shapes (e.g., a circular footprint). Source dilation also is slower for larger images and larger footprints. Examples -------- >>> import numpy as np >>> from photutils.segmentation import SegmentationImage >>> from photutils.utils import circular_footprint >>> data = np.zeros((7, 7), dtype=int) >>> data[3, 3] = 1 >>> segm = SegmentationImage(data) >>> segm.data array([[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0]]) >>> mask0 = segm.make_source_mask() >>> mask0 array([[False, False, False, False, False, False, False], [False, False, False, False, False, False, False], [False, False, False, False, False, False, False], [False, False, False, True, False, False, False], [False, False, False, False, False, False, False], [False, False, False, False, False, False, False], [False, False, False, False, False, False, False]]) >>> mask1 = segm.make_source_mask(size=3) >>> mask1 array([[False, False, False, False, False, False, False], [False, False, False, False, False, False, False], [False, False, True, True, True, False, False], [False, False, True, True, True, False, False], [False, False, True, True, True, False, False], [False, False, False, False, False, False, False], [False, False, False, False, False, False, False]]) >>> footprint = circular_footprint(radius=3) >>> mask2 = segm.make_source_mask(footprint=footprint) >>> mask2 array([[False, False, False, True, False, False, False], [False, True, True, True, True, True, False], [False, True, True, True, True, True, False], [ True, True, True, True, True, True, True], [False, True, True, True, True, True, False], [False, True, True, True, True, True, False], [False, False, False, True, False, False, False]]) """ mask = self._data.astype(bool) if footprint is None: if size is None: return mask size = as_pair('size', size, check_odd=False) footprint = np.ones(size, dtype=bool) footprint = footprint.astype(bool) if np.all(footprint): # With a rectangular footprint, scipy's grey_dilation is # currently much faster than binary_dilation (separable # footprint). grey_dilation and binary_dilation are identical # for binary inputs (equivalent to a 2D maximum filter). return grey_dilation(mask, footprint=footprint) # Binary dilation is very slow, especially for large # footprints. The following is a faster implementation # using fast Fourier transforms (FFTs) that gives identical # results to binary_dilation. Based on the following paper: # "Dilation and Erosion of Gray Images with Spherical # Masks", J. Kukal, D. Majerova, A. Prochazka (Jan 2007). # https://www.researchgate.net/publication/238778666_DILATION_AND_EROSION_OF_GRAY_IMAGES_WITH_SPHERICAL_MASKS return fftconvolve(mask, footprint, 'same') > 0.5 @lazyproperty def _geo_polygons(self): """ A list of polygons representing each source segment. Each item in the list is tuple of (polygon, value) where the polygon is a GeoJSON-like dict and the value is the label from the segmentation image. Note that the coordinates of these polygon vertices are in a reference frame with the (0, 0) origin at the *lower-left* corner of the lower-left pixel. """ from rasterio.features import shapes polygons = list(shapes(self.data.astype('int32'), connectivity=8)) polygons.sort(key=lambda x: x[1]) # sort in label order # do not include polygons for background (label = 0) return polygons[1:] @lazyproperty def polygons(self): """ A list of `Shapely `_ polygons representing each source segment. """ from shapely import transform from shapely.geometry import shape polygons = [shape(geo_poly[0]) for geo_poly in self._geo_polygons if geo_poly[1] != 0] # shift the vertices so that the (0, 0) origin is at the # center of the lower-left pixel return transform(polygons, lambda x: x - [0.5, 0.5]) @staticmethod def _get_polygon_vertices(polygon, origin=(0, 0), scale=1.0): xy = np.array(polygon.exterior.coords) xy = scale * (xy + 0.5) - 0.5 xy -= origin return xy def to_regions(self): """ Return a `regions.Regions` object containing a list of `~regions.PolygonPixelRegion` objects representing each source segment. Returns ------- regions : `~regions.Regions` A list of `~regions.PolygonPixelRegion` objects stored in a `~regions.Regions` object. Notes ----- The polygons can be written to a file using the :meth:`regions.Regions.write` method. """ from regions import Regions return Regions([_shapely_polygon_to_region(poly) for poly in self.polygons]) def to_patches(self, *, origin=(0, 0), scale=1.0, **kwargs): """ Return a list of `~matplotlib.patches.Polygon` objects representing each source segment. By default, the polygon patch will have a white edge color and no face color. Parameters ---------- origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. This effectively translates the position of the polygons. scale : float, optional The scale factor applied to the polygon vertices. **kwargs : dict, optional Any keyword arguments accepted by `matplotlib.patches.Polygon`. Returns ------- patches : list of `~matplotlib.patches.Polygon` A list of matplotlib polygon patches for the source segments. """ from matplotlib.patches import Polygon origin = np.array(origin) patch_kwargs = {'edgecolor': 'white', 'facecolor': 'none'} patch_kwargs.update(kwargs) patches = [] for poly in self.polygons: xy = self._get_polygon_vertices(poly, origin=origin, scale=scale) patches.append(Polygon(xy, **patch_kwargs)) return patches def plot_patches(self, *, ax=None, origin=(0, 0), scale=1.0, labels=None, **kwargs): """ Plot the `~matplotlib.patches.Polygon` objects for the source segments on a matplotlib `~matplotlib.axes.Axes` instance. Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then the current `~matplotlib.axes.Axes` instance is used. origin : array_like, optional The ``(x, y)`` position of the origin of the displayed image. scale : float, optional The scale factor applied to the polygon vertices. labels : int or array of int, optional The label numbers whose polygons are to be plotted. If `None`, the polygons for all labels will be plotted. **kwargs : dict, optional Any keyword arguments accepted by `matplotlib.patches.Polygon`. Returns ------- patches : list of `~matplotlib.patches.Polygon` A list of matplotlib polygon patches for the plotted polygons. The patches can be used, for example, when adding a plot legend. Examples -------- .. plot:: :include-source: import numpy as np from photutils.segmentation import SegmentationImage data = np.array([[1, 1, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) segm = SegmentationImage(data) segm.imshow(figsize=(5, 5)) segm.plot_patches(edgecolor='white', lw=2) """ import matplotlib.pyplot as plt if ax is None: ax = plt.gca() patches = self.to_patches(origin=origin, scale=scale, **kwargs) if labels is not None: patches = np.array(patches) indices = self.get_indices(labels) patches = patches[indices] if np.isscalar(labels): patches = [patches] for patch in patches: patch = copy(patch) ax.add_patch(patch) if labels is not None: patches = list(patches) return patches def imshow(self, ax=None, figsize=None, dpi=None, cmap=None, alpha=None): """ Display the segmentation image in a matplotlib `~matplotlib.axes.Axes` instance. The segmentation image will be displayed with "nearest" interpolation and with the origin set to "lower". Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then a new `~matplotlib.axes.Axes` instance will be created. figsize : 2-tuple of floats or `None`, optional The figure dimension (width, height) in inches when creating a new Axes. This keyword is ignored if ``axes`` is input. dpi : float or `None`, optional The figure dots per inch when creating a new Axes. This keyword is ignored if ``axes`` is input. cmap : `matplotlib.colors.Colormap`, str, or `None`, optional The `~matplotlib.colors.Colormap` instance or a registered matplotlib colormap name used to map scalar data to colors. If `None`, then the colormap defined by the `cmap` attribute will be used. alpha : float, array_like, or `None`, optional The alpha blending value, between 0 (transparent) and 1 (opaque). If alpha is an array, the alpha blending values are applied pixel by pixel, and alpha must have the same shape as the segmentation image. Returns ------- result : `matplotlib.image.AxesImage` An image attached to an `matplotlib.axes.Axes`. Examples -------- .. plot:: :include-source: import 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) def imshow_map(self, ax=None, figsize=None, dpi=None, cmap=None, alpha=None, max_labels=25, cbar_labelsize=None): """ Display the segmentation image in a matplotlib `~matplotlib.axes.Axes` instance with a colorbar. This method is useful for displaying segmentation images that have a small number of labels (e.g., from a cutout) that are not consecutive. It maps the labels to be consecutive integers starting from 1 before plotting. The plotted image values are not the label values, but the colorbar tick labels are used to show the original labels. The segmentation image will be displayed with "nearest" interpolation and with the origin set to "lower". Parameters ---------- ax : `matplotlib.axes.Axes` or `None`, optional The matplotlib axes on which to plot. If `None`, then a new `~matplotlib.axes.Axes` instance will be created. figsize : 2-tuple of floats or `None`, optional The figure dimension (width, height) in inches when creating a new Axes. This keyword is ignored if ``axes`` is input. dpi : float or `None`, optional The figure dots per inch when creating a new Axes. This keyword is ignored if ``axes`` is input. cmap : `matplotlib.colors.Colormap`, str, or `None`, optional The `~matplotlib.colors.Colormap` instance or a registered matplotlib colormap name used to map scalar data to colors. If `None`, then the colormap defined by the `cmap` attribute will be used. alpha : float, array_like, or `None`, optional The alpha blending value, between 0 (transparent) and 1 (opaque). If alpha is an array, the alpha blending values are applied pixel by pixel, and alpha must have the same shape as the segmentation image. max_labels : int, optional The maximum number of labels to display in the colorbar. If the number of labels is greater than ``max_labels``, then the colorbar will not be displayed. cbar_labelsize : `None` or float, optional The font size of the colorbar tick labels. Returns ------- result : `matplotlib.image.AxesImage` An image attached to an `matplotlib.axes.Axes`. cbar_info : tuple or `None` The colorbar information as a tuple containing the `~matplotlib.colorbar.Colorbar` instance, a `~numpy.ndarray` of tick positions, and a `~numpy.ndarray` of tick labels. `None` is returned if the colorbar was not plotted. Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt import numpy as np from photutils.segmentation import SegmentationImage data = np.array([[1, 1, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) data *= 1000 segm = SegmentationImage(data) fig, ax = plt.subplots() im, cbar = segm.imshow_map(ax=ax) """ import matplotlib.pyplot as plt from matplotlib.colors import ListedColormap if ax is None: _, ax = plt.subplots(figsize=figsize, dpi=dpi) data, idx = np.unique(self.data, return_inverse=True) idx = idx.reshape(self.data.shape) vmin = -0.5 vmax = np.max(idx) + 0.5 # keep the original cmap colors for the labels if cmap is None: cmap = ListedColormap(self.cmap.colors[data]) im = ax.imshow(idx, cmap=cmap, interpolation='nearest', origin='lower', alpha=alpha, vmin=vmin, vmax=vmax) cbar_info = None cbar_labels = np.hstack((0, self.labels)) if len(cbar_labels) <= max_labels: cbar_ticks = np.arange(len(cbar_labels)) cbar = plt.colorbar(im, ax=ax, ticks=cbar_ticks) cbar.ax.set_yticklabels(cbar_labels) if cbar_labelsize is not None: cbar.ax.yaxis.set_tick_params(labelsize=cbar_labelsize) cbar_info = (cbar, cbar_ticks, cbar_labels) else: warnings.warn('The colorbar was not plotted because the number of ' f'labels is greater than {max_labels=}.', AstropyUserWarning) return im, cbar_info class Segment: """ Class for a single labeled region (segment) within a segmentation image. Parameters ---------- segment_data : int `~numpy.ndarray` A segmentation array where source regions are labeled by different positive integer values. A value of zero is reserved for the background. label : int The segment label number. slices : tuple of two slices A tuple of two slices representing the minimal box that contains the labeled region. bbox : `~photutils.aperture.BoundingBox` The minimal bounding box that contains the labeled region. area : float The area of the segment in pixels**2. polygon : Shapely polygon, optional The outline of the segment as a `Shapely `_ polygon. """ def __init__(self, segment_data, label, slices, bbox, area, *, polygon=None): self._segment_data = segment_data self.label = label self.slices = slices self.bbox = bbox self.area = area self.polygon = polygon def __str__(self): cls_name = f'<{self.__class__.__module__}.{self.__class__.__name__}>' 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__() def _repr_svg_(self): # pragma: no cover if self.polygon is not None: return self.polygon._repr_svg_() return None def __array__(self): """ Array representation of the labeled region (e.g., for matplotlib). """ return self.data @lazyproperty def data(self): """ A cutout array of the segment using the minimal bounding box, where pixels outside of the labeled region are set to zero (i.e., neighboring segments within the rectangular cutout array are not shown). """ cutout = np.copy(self._segment_data[self.slices]) cutout[cutout != self.label] = 0 return cutout @lazyproperty def data_ma(self): """ A `~numpy.ma.MaskedArray` cutout array of the segment using the minimal bounding box. The mask is `True` for pixels outside of the source segment (i.e., neighboring segments within the rectangular cutout array are masked). """ mask = (self._segment_data[self.slices] != self.label) return np.ma.masked_array(self._segment_data[self.slices], mask=mask) def make_cutout(self, data, masked_array=False): """ Create a (masked) cutout array from the input ``data`` using the minimal bounding box of the segment (labeled region). If ``masked_array`` is `False` (default), then the returned cutout array is simply a `~numpy.ndarray`. The returned cutout is a view (not a copy) of the input ``data``. No pixels are altered (e.g., set to zero) within the bounding box. If ``masked_array`` is `True`, then the returned cutout array is a `~numpy.ma.MaskedArray`, where the mask is `True` for pixels outside of the segment (labeled region). The data part of the masked array is a view (not a copy) of the input ``data``. Parameters ---------- data : 2D `~numpy.ndarray` The data array from which to create the masked cutout array. ``data`` must have the same shape as the segmentation array. masked_array : bool, optional If `True` then a `~numpy.ma.MaskedArray` will be created where the mask is `True` for pixels outside of the segment (labeled region). If `False`, then a `~numpy.ndarray` will be generated. Returns ------- result : 2D `~numpy.ndarray` or `~numpy.ma.MaskedArray` The cutout array. """ if data.shape != self._segment_data.shape: raise ValueError('data must have the same shape as the ' 'segmentation array.') if masked_array: mask = (self._segment_data[self.slices] != self.label) return np.ma.masked_array(data[self.slices], mask=mask) return data[self.slices] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/segmentation/deblend.py0000644000175100001660000007106614755160622021672 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for deblending overlapping sources labeled in a segmentation image. """ import warnings from concurrent.futures import ProcessPoolExecutor, as_completed from dataclasses import dataclass from functools import partial from multiprocessing import cpu_count, get_context import numpy as np from astropy.units import Quantity from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from scipy.ndimage import label as ndi_label from scipy.ndimage import sum_labels from photutils.segmentation.core import SegmentationImage from photutils.segmentation.detect import _detect_sources from photutils.segmentation.utils import _make_binary_structure from photutils.utils._optional_deps import tqdm from photutils.utils._progress_bars import add_progress_bar from photutils.utils._stats import nanmax, nanmin, nansum __all__ = ['deblend_sources'] @dataclass class _DeblendParams: npixels: int footprint: np.ndarray nlevels: int contrast: float mode: str def deblend_sources(data, segment_img, npixels, *, labels=None, nlevels=32, contrast=0.001, mode='exponential', connectivity=8, relabel=True, nproc=1, progress_bar=True): """ Deblend overlapping sources labeled in a segmentation image. Sources are deblended using a combination of multi-thresholding and `watershed segmentation `_. In order to deblend sources, there must be a saddle between them. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array of the image. If filtering is desired, please input a convolved image here. This array should be the same array used in `~photutils.segmentation.detect_sources`. segment_img : `~photutils.segmentation.SegmentationImage` The segmentation image to deblend. npixels : int The minimum number of connected pixels, each greater than ``threshold``, that an object must have to be deblended. ``npixels`` must be a positive integer. labels : int or array_like of int, optional The label numbers to deblend. If `None` (default), then all labels in the segmentation image will be deblended. nlevels : int, optional The number of multi-thresholding levels to use for deblending. Each source will be re-thresholded at ``nlevels`` levels spaced between its minimum and maximum values (non-inclusive). The ``mode`` keyword determines how the levels are spaced. contrast : float, optional The fraction of the total source flux that a local peak must have (at any one of the multi-thresholds) to be deblended as a separate object. ``contrast`` must be between 0 and 1, inclusive. If ``contrast=0`` then every local peak will be made a separate object (maximum deblending). If ``contrast=1`` then no deblending will occur. The default is 0.001, which will deblend sources with a 7.5 magnitude difference. mode : {'exponential', 'linear', 'sinh'}, optional The mode used in defining the spacing between the multi-thresholding levels (see the ``nlevels`` keyword) during deblending. The ``'exponential'`` and ``'sinh'`` modes have more threshold levels near the source minimum and less near the source maximum. The ``'linear'`` mode evenly spaces the threshold levels between the source minimum and maximum. The ``'exponential'`` and ``'sinh'`` modes differ in that the ``'exponential'`` levels are dependent on the source maximum/minimum ratio (smaller ratios are more linear; larger ratios are more exponential), while the ``'sinh'`` levels are not. Also, the ``'exponential'`` mode will be changed to ``'linear'`` for sources with non-positive minimum data values. connectivity : {8, 4}, optional The type of pixel connectivity used in determining how pixels are grouped into a detected source. The options are 8 (default) or 4. 8-connected pixels touch along their edges or corners. 4-connected pixels touch along their edges. The ``connectivity`` must be the same as that used to create the input segmentation image. relabel : bool, optional If `True` (default), then the segmentation image will be relabeled such that the labels are in consecutive order starting from 1. nproc : int, optional The number of processes to use for multiprocessing (if larger than 1). If set to 1, then a serial implementation is used instead of a parallel one. If `None`, then the number of processes will be set to the number of CPUs detected on the machine. Please note that due to overheads, multiprocessing may be slower than serial processing if only a small number of sources are to be deblended. The benefits of multiprocessing require ~1000 or more sources to deblend, with larger gains as the number of sources increase. progress_bar : bool, optional Whether to display a progress bar. If ``nproc = 1``, then the ID shown after the progress bar is the source label being deblended. If multiprocessing is used (``nproc > 1``), the ID shown is the last source label that was deblended. The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. Returns ------- segment_image : `~photutils.segmentation.SegmentationImage` A segmentation image, with the same shape as ``data``, where sources are marked by different positive integer values. A value of zero is reserved for the background. See Also -------- :func:`photutils.segmentation.detect_sources` :class:`photutils.segmentation.SourceFinder` """ if isinstance(data, Quantity): data = data.value if not isinstance(segment_img, SegmentationImage): raise TypeError('segment_img must be a SegmentationImage') if segment_img.shape != data.shape: raise ValueError('The data and segmentation image must have ' 'the same shape') if nlevels < 1: raise ValueError('nlevels must be >= 1') if contrast < 0 or contrast > 1: raise ValueError('contrast must be >= 0 and <= 1') if contrast == 1: # no deblending return segment_img.copy() if mode not in ('exponential', 'linear', 'sinh'): raise ValueError('mode must be "exponential", "linear", or "sinh"') if labels is None: labels = segment_img.labels else: labels = np.atleast_1d(labels) segment_img.check_labels(labels) # include only sources that have at least (2 * npixels); # this is required for a source to be deblended into multiple # sources, each with a minimum of npixels mask = (segment_img.areas[segment_img.get_indices(labels)] >= (npixels * 2)) labels = labels[mask] footprint = _make_binary_structure(data.ndim, connectivity) deblend_params = _DeblendParams(npixels, footprint, nlevels, contrast, mode) segm_deblended = segment_img.data.copy() label_indices = segment_img.get_indices(labels) if nproc is None: nproc = cpu_count() # pragma: no cover deblend_label_map = {} max_label = segment_img.max_label if nproc == 1: if progress_bar: # pragma: no cover desc = 'Deblending' label_indices = add_progress_bar(label_indices, desc=desc) nonposmin_labels = [] nmarkers_labels = [] for label, label_idx in zip(labels, label_indices, strict=True): if not isinstance(label_indices, np.ndarray): label_indices.set_postfix_str(f'ID: {label}') source_slice = segment_img.slices[label_idx] source_data = data[source_slice] source_segment = segment_img.data[source_slice] source_deblended, warns = _deblend_source(source_data, source_segment, label, deblend_params) if warns: if 'nonposmin' in warns: nonposmin_labels.append(label) if 'nmarkers' in warns: nmarkers_labels.append(label) if source_deblended is not None: source_mask = source_deblended > 0 new_segm = source_deblended[source_mask] # min label = 1 segm_deblended[source_slice][source_mask] = ( new_segm + max_label) new_labels = _get_labels(new_segm) + max_label deblend_label_map[label] = new_labels max_label += len(new_labels) else: # Use multiprocessing to deblend sources # Prepare the arguments for the worker function all_source_data = [] all_source_segments = [] all_source_slices = [] for label_idx in label_indices: source_slice = segment_img.slices[label_idx] source_data = data[source_slice] source_segment = segment_img.data[source_slice] all_source_data.append(source_data) all_source_segments.append(source_segment) all_source_slices.append(source_slice) args_all = zip(all_source_data, all_source_segments, labels, strict=True) # Create a partial function to pass the deblend_params to the # worker function worker = partial(_deblend_source, deblend_params=deblend_params) # Prepare to store futures and results to preserve the input # order of the labels when using as_completed() futures_dict = {} results = [None] * len(labels) disable_pbar = not progress_bar mp_context = get_context('spawn') with ProcessPoolExecutor(mp_context=mp_context, max_workers=nproc) as executor: # Submit all jobs at once for index, args in enumerate(args_all): futures_dict[executor.submit(worker, *args)] = index with tqdm(total=len(labels), desc='Deblending', disable=disable_pbar) as pbar: # Process the results as they are completed for future in as_completed(futures_dict): pbar.update(1) idx = futures_dict[future] pbar.set_postfix_str(f'ID: {labels[idx]}') results[idx] = future.result() # Process the results nonposmin_labels = [] nmarkers_labels = [] for label, source_slice, source_deblended in zip(labels, all_source_slices, results, strict=True): source_deblended, warns = source_deblended if warns: if 'nonposmin' in warns: nonposmin_labels.append(label) if 'nmarkers' in warns: nmarkers_labels.append(label) if source_deblended is not None: source_mask = source_deblended > 0 new_segm = source_deblended[source_mask] # min label = 1 segm_deblended[source_slice][source_mask] = ( new_segm + max_label) new_labels = _get_labels(new_segm) + max_label deblend_label_map[label] = new_labels max_label += len(new_labels) # process any warnings during deblending warning_info = {} if nonposmin_labels or nmarkers_labels: msg = ('The deblending mode of one or more source labels from the ' f'input segmentation image was changed from "{mode}" to ' '"linear". See the "info" attribute for the list of affected ' 'input labels.') warnings.warn(msg, AstropyUserWarning) if nonposmin_labels: nonposmin_labels = np.array(nonposmin_labels) msg = (f'Deblending mode changed from {mode} to linear due to ' 'non-positive minimum data values.') warn = {'message': msg, 'input_labels': nonposmin_labels} warning_info['nonposmin'] = warn if nmarkers_labels: nmarkers_labels = np.array(nmarkers_labels) msg = (f'Deblending mode changed from {mode} to linear due to ' 'too many potential deblended sources.') warn = {'message': msg, 'input_labels': nmarkers_labels} warning_info['nmarkers'] = warn if relabel: relabel_map = _create_relabel_map(segm_deblended, start_label=1) if relabel_map is not None: segm_deblended = relabel_map[segm_deblended] deblend_label_map = _update_deblend_label_map(deblend_label_map, relabel_map) segm_img = object.__new__(SegmentationImage) segm_img._data = segm_deblended segm_img._deblend_label_map = deblend_label_map # store the warnings in the output SegmentationImage info attribute if warning_info: segm_img.info = {'warnings': warning_info} return segm_img def _deblend_source(data, segment_data, label, deblend_params): """ Convenience function to deblend a single labeled source. """ deblender = _SingleSourceDeblender(data, segment_data, label, deblend_params) return deblender.deblend_source(), deblender.warnings class _SingleSourceDeblender: """ Class to deblend a single labeled source. Parameters ---------- data : 2D `~numpy.ndarray` The cutout data array for a single source. ``data`` should also already be smoothed by the same filter used in :func:`~photutils.segmentation.detect_sources`, if applicable. segment_data : 2D int `~numpy.ndarray` The cutout segmentation image for a single source. Must have the same shape as ``data``. label : int The label of the source to deblend. This is needed because there may be more than one source label within the cutout. npixels : int The number of connected pixels, each greater than ``threshold``, that an object must have to be detected. ``npixels`` must be a positive integer. nlevels : int The number of multi-thresholding levels to use. Each source will be re-thresholded at ``nlevels`` levels spaced between its minimum and maximum values within the source segment. See the ``mode`` keyword for how the levels are spaced. contrast : float The fraction of the total (blended) source flux that a local peak must have (at any one of the multi-thresholds) to be considered as a separate object. ``contrast`` must be between 0 and 1, inclusive. If ``contrast = 0`` then every local peak will be made a separate object (maximum deblending). If ``contrast = 1`` then no deblending will occur. The default is 0.001, which will deblend sources with a 7.5 magnitude difference. mode : {'exponential', 'linear', 'sinh'} The mode used in defining the spacing between the multi-thresholding levels (see the ``nlevels`` keyword). Returns ------- segment_image : `~photutils.segmentation.SegmentationImage` A segmentation image, with the same shape as ``data``, where sources are marked by different positive integer values. A value of zero is reserved for the background. Note that the returned `SegmentationImage` will have consecutive labels starting with 1. """ def __init__(self, data, segment_data, label, deblend_params): self.data = data self.segment_data = segment_data self.label = label self.npixels = deblend_params.npixels self.footprint = deblend_params.footprint self.nlevels = deblend_params.nlevels self.contrast = deblend_params.contrast self.mode = deblend_params.mode self.segment_mask = segment_data == label data_values = data[self.segment_mask] self.source_min = nanmin(data_values) self.source_max = nanmax(data_values) self.source_sum = nansum(data_values) self.warnings = {} @lazyproperty def linear_thresholds(self): """ Linearly spaced thresholds between the source minimum and maximum (inclusive). The source min/max are excluded later, giving nlevels thresholds between min and max (noninclusive). """ return np.linspace(self.source_min, self.source_max, self.nlevels + 2) @lazyproperty def normalized_thresholds(self): """ Normalized thresholds (from 0 to 1) between the source minimum and maximum (inclusive). """ return ((self.linear_thresholds - self.source_min) / (self.source_max - self.source_min)) def compute_thresholds(self): """ Compute the multi-level detection thresholds for the source. Returns ------- thresholds : 1D `~numpy.ndarray` The multi-level detection thresholds for the source. """ if self.mode == 'exponential' and self.source_min <= 0: self.warnings['nonposmin'] = 'non-positive minimum' self.mode = 'linear' if self.mode == 'linear': thresholds = self.linear_thresholds elif self.mode == 'sinh': a = 0.25 minval = self.source_min maxval = self.source_max thresholds = self.normalized_thresholds thresholds = np.sinh(thresholds / a) / np.sinh(1.0 / a) thresholds *= (maxval - minval) thresholds += minval elif self.mode == 'exponential': minval = self.source_min maxval = self.source_max thresholds = self.normalized_thresholds thresholds = minval * (maxval / minval) ** thresholds return thresholds[1:-1] # do not include source min and max def multithreshold(self): """ Perform multithreshold detection for each source. This method is useful for debugging and testing. Parameters ---------- deblend_mode : bool, optional If `True` then only segmentation images with more than one label will be returned. If `False` then all segmentation images will be returned. Returns ------- segments : list of 2D `~numpy.ndarray` A list of segmentation images, one for each threshold. Only segmentation images with more than one label will be returned. """ thresholds = self.compute_thresholds() segms = [] for threshold in thresholds: segm = _detect_sources(self.data, threshold, self.npixels, self.footprint, self.segment_mask, relabel=False, return_segmimg=False) segms.append(segm) return segms def make_markers(self, return_all=False): """ Make markers (possible sources) for the watershed algorithm. Parameters ---------- return_all : bool, optional If `False` then return only the final segmentation marker image. If `True` then return all segmentation marker images. This keyword is useful for debugging and testing. Returns ------- markers : 2D `~numpy.ndarray` or list of 2D `~numpy.ndarray` A segmentation image that contain markers for possible sources. If ``return_all=True`` then a list of all segmentation marker images is returned. `None` is returned if there is only one source at every threshold. """ thresholds = self.compute_thresholds() segm_lower = _detect_sources(self.data, thresholds[0], self.npixels, self.footprint, self.segment_mask, relabel=False, return_segmimg=False) if return_all: all_segms = [segm_lower] for threshold in thresholds[1:]: segm_upper = _detect_sources(self.data, threshold, self.npixels, self.footprint, self.segment_mask, relabel=False, return_segmimg=False) if segm_upper is None: # 0 or 1 labels continue segm_lower = self.make_marker_segment(segm_lower, segm_upper) if return_all: all_segms.append(segm_lower) if return_all: return all_segms return segm_lower def make_marker_segment(self, segment_lower, segment_upper): """ Make markers (possible sources) for the watershed algorithm. Parameters ---------- segment_lower : 2D `~numpy.ndarray` The "lower" threshold level segmentation image. segment_upper : 2D `~numpy.ndarray` The next-highest threshold level segmentation image. Returns ------- markers : 2D `~numpy.ndarray` A segmentation image that contain markers for possible sources. Notes ----- For a given label in the lower level, find the labels in the upper level (higher threshold value) that are its children (i.e., the labels within the same mask as the lower level). If there are multiple children, then the lower-level parent label is replaced by its children. Parent labels that do not have multiple children in the upper level are kept as is (maximizing the marker size). """ if segment_lower is None: return segment_upper labels = _get_labels(segment_lower) new_markers = False markers = segment_lower.astype(bool) for label in labels: mask = (segment_lower == label) # find label mapping from the lower to upper level upper_labels = _get_labels(segment_upper[mask]) if upper_labels.size >= 2: # new child markers found new_markers = True markers[mask] = segment_upper[mask].astype(bool) if new_markers: # convert bool markers to integer labels return ndi_label(markers, structure=self.footprint)[0] return segment_lower def apply_watershed(self, markers): """ Apply the watershed algorithm to the source markers. Parameters ---------- markers : list of `~photutils.segmentation.SegmentationImage` A list of segmentation images that contain possible sources as markers. The last list element contains all of the potential source markers. Returns ------- segment_data : 2D int `~numpy.ndarray` A 2D int array containing the deblended source labels. Note that the source labels may not be consecutive if a label was removed. """ from skimage.segmentation import watershed # Deblend using watershed. If any source does not meet the contrast # criterion, then remove the faintest such source and repeat until # all sources meet the contrast criterion. remove_marker = True while remove_marker: markers = watershed(-self.data, markers, mask=self.segment_mask, connectivity=self.footprint) labels = _get_labels(markers) if labels.size == 1: # only 1 source left remove_marker = False else: flux_frac = (sum_labels(self.data, markers, index=labels) / self.source_sum) remove_marker = any(flux_frac < self.contrast) if remove_marker: # remove only the faintest source (one at a time) # because several faint sources could combine to meet # the contrast criterion markers[markers == labels[np.argmin(flux_frac)]] = 0.0 return markers def deblend_source(self): """ Deblend a single labeled source. Returns ------- segment_data : 2D int `~numpy.ndarray` A 2D int array containing the deblended source labels. The source labels are consecutive starting at 1. """ if self.source_min == self.source_max: # no deblending return None # define the markers (possible sources) for the watershed algorithm markers = self.make_markers() if markers is None: return None # If there are too many markers (e.g., due to low threshold # and/or small npixels), the watershed step can be very slow # (the threshold of 200 is arbitrary, but seems to work well). # This mostly affects the "exponential" mode, where there are # many levels at low thresholds, so here we try again with # "linear" mode. nlabels = len(_get_labels(markers)) if self.mode != 'linear' and nlabels > 200: del markers # free memory self.warnings['nmarkers'] = 'too many markers' self.mode = 'linear' markers = self.make_markers() if markers is None: return None # deblend using the watershed algorithm using the markers as seeds markers = self.apply_watershed(markers) if not np.array_equal(self.segment_mask, markers.astype(bool)): raise ValueError(f'Deblending failed for source "{self.label}". ' 'Please ensure you used the same pixel ' 'connectivity in detect_sources and ' 'deblend_sources.') if len(_get_labels(markers)) == 1: # no deblending return None # markers may not be consecutive if a label was removed due to # the contrast criterion relabel_map = _create_relabel_map(markers, start_label=1) if relabel_map is not None: markers = relabel_map[markers] return markers def _get_labels(array): """ Get the unique labels greater than zero in an array. Parameters ---------- array : `~numpy.ndarray` The array to get the unique labels from. Returns ------- labels : int `~numpy.ndarray` The unique labels in the array. """ labels = np.unique(array) return labels[labels != 0] def _create_relabel_map(array, start_label=1): """ Create a mapping of original labels to new labels that are consecutive integers. By default, the new labels start from 1. Parameters ---------- array : 2D `~numpy.ndarray` The 2D array to relabel. start_label : int, optional The starting label number. Must be >= 1. The default is 1. Returns ------- relabel_map : 1D `~numpy.ndarray` or None The array mapping the original labels to the new labels. If the labels are already consecutive starting from ``start_label``, then `None` is returned. """ labels = _get_labels(array) # check if the labels are already consecutive starting from # start_label if (labels[0] == start_label and (labels[-1] - start_label + 1) == len(labels)): return None # Create an array to map old labels to new labels relabel_map = np.zeros(labels.max() + 1, dtype=array.dtype) relabel_map[labels] = np.arange(len(labels)) + start_label return relabel_map def _update_deblend_label_map(deblend_label_map, relabel_map): """ Update the deblend_label_map to reflect the new labels that are consecutive integers. Parameters ---------- deblend_label_map : dict A dictionary mapping the original labels to the new deblended labels. relabel_map : 1D `~numpy.ndarray` The array mapping the original labels to the new labels. Returns ------- deblend_label_map : dict The updated deblend_label_map. """ for old_label, new_labels in deblend_label_map.items(): deblend_label_map[old_label] = relabel_map[new_labels] return deblend_label_map ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/segmentation/detect.py0000644000175100001660000003474514755160622021550 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for detecting sources in an image. """ import warnings import numpy as np from astropy.stats import SigmaClip from 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._quantity_helpers import process_quantities from photutils.utils._stats import nanmean, nanstd from photutils.utils.exceptions import NoDetectionsWarning __all__ = ['detect_sources', 'detect_threshold'] def detect_threshold(data, nsigma, *, background=None, error=None, mask=None, sigma_clip=SigmaClip(sigma=3.0, maxiters=10)): """ Calculate a pixel-wise threshold image that can be used to detect sources. This is a simple convenience function that uses sigma-clipped statistics to compute a scalar background and noise estimate. In general, one should perform more sophisticated estimates, e.g., using `~photutils.background.Background2D`. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array of the image. nsigma : float The number of standard deviations per pixel above the ``background`` for which to consider a pixel as possibly being part of a source. background : float or 2D `~numpy.ndarray`, optional The background value(s) of the input ``data``. ``background`` may either be a scalar value or a 2D array with the same shape as the input ``data``. If the input ``data`` has been background-subtracted, then set ``background`` to ``0.0`` (this should be typical). If `None`, then a scalar background value will be estimated as the sigma-clipped image mean. error : float or 2D `~numpy.ndarray`, optional The Gaussian 1-sigma standard deviation of the background noise in ``data``. ``error`` should include all sources of "background" error, but *exclude* the Poisson error of the sources. If ``error`` is a 2D image, then it should represent the 1-sigma background error in each pixel of ``data``. If `None`, then a scalar background rms value will be estimated as the sigma-clipped image standard deviation. mask : 2D bool `~numpy.ndarray`, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked pixels are ignored when computing the image background statistics. sigma_clip : `astropy.stats.SigmaClip` instance, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters. Returns ------- threshold : 2D `~numpy.ndarray` A 2D image with the same shape (and units) as ``data`` containing the pixel-wise threshold values. See Also -------- :class:`photutils.background.Background2D` :func:`photutils.segmentation.detect_sources` :class:`photutils.segmentation.SourceFinder` Notes ----- The ``mask`` and ``sigma_clip`` inputs are used only if it is necessary to estimate ``background`` or ``error`` using sigma-clipped background statistics. If ``background`` and ``error`` are both input, then ``mask`` and ``sigma_clip`` are ignored. """ inputs = (data, background, error) names = ('data', 'background', 'error') inputs, unit = process_quantities(inputs, names) (data, background, error) = inputs if not isinstance(sigma_clip, SigmaClip): raise TypeError('sigma_clip must be a SigmaClip object') if background is None or error is None: if mask is not None: data = np.ma.MaskedArray(data, mask) clipped_data = sigma_clip(data, masked=False, return_bounds=False, copy=True) if background is None: background = nanmean(clipped_data) if not np.isscalar(background) and background.shape != data.shape: raise ValueError('If input background is 2D, then it must have the ' 'same shape as the input data.') if error is None: error = nanstd(clipped_data) if not np.isscalar(error) and error.shape != data.shape: raise ValueError('If input error is 2D, then it must have the same ' 'shape as the input data.') threshold = (np.broadcast_to(background, data.shape) + np.broadcast_to(error * nsigma, data.shape)) if unit: threshold <<= unit return threshold def _detect_sources(data, threshold, npixels, footprint, inverse_mask, *, relabel=True, return_segmimg=True): """ Detect sources above a specified threshold value in an image. Detected sources must have ``npixels`` connected pixels that are each greater than the ``threshold`` value in the input ``data``. This function is the core algorithm for detecting sources in an image used by `detect_sources`. This function differs from `detect_sources` in that it does not perform any boilerplate checks, it accepts a ``footprint`` argument instead of a ``connectivity`` argument, and it accepts an ``inverse_mask`` argument instead of a ``mask`` argument. It is also used by the source deblending function for multithresholding. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array of the image. If filtering is desired, please input a convolved image. threshold : float or 2D `~numpy.ndarray` The data value or pixel-wise data values to be used for the detection threshold. If ``data`` is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units as ``data``. A 2D ``threshold`` array must have the same shape as ``data``. npixels : int The minimum number of connected pixels, each greater than ``threshold``, that an object must have to be detected. ``npixels`` must be a positive integer. footprint : array_like A footprint that defines feature connections. As an example, for connectivity along pixel edges only, the footprint is ``np.array([[0, 1, 0]], [1, 1, 1], [0, 1, 0]])``. inverse_mask : 2D bool `~numpy.ndarray` A boolean mask, with the same shape as the input ``data``, where `False` values indicate masked pixels (the inverse of usual pixel masks). Masked pixels will not be included in any source. relabel : bool, optional If `True`, relabel the segmentation image with consecutive numbers. return_segmimg : bool, optional If `True`, return a `~photutils.segmentation.SegmentationImage` object. If `False`, return a 2D `~numpy.ndarray` segmentation image. The latter is used by the source deblending function. In that case, if only one source is found, then `None` is returned. Returns ------- segment_image : `~photutils.segmentation.SegmentationImage`, \ 2D `~numpy.ndarray`, or `None` A 2D segmentation image, with the same shape as ``data``, where sources are marked by different positive integer values. A value of zero is reserved for the background. If ``return_segmimg`` is `False`, then a 2D `~numpy.ndarray` segmentation image is returned. If no sources are found then `None` is returned. """ # ignore RuntimeWarning caused by > comparison when data contains NaNs with warnings.catch_warnings(): warnings.simplefilter('ignore', category=RuntimeWarning) segment_img = data > threshold if inverse_mask is not None: segment_img &= inverse_mask # return None if threshold was too high to detect any sources if np.count_nonzero(segment_img) == 0: return None # NOTE: recasting segment_img to int and using output=segment_img # gives similar performance segment_img, nlabels = ndi_label(segment_img, structure=footprint) labels = np.arange(nlabels, dtype=segment_img.dtype) + 1 # remove objects with less than npixels # NOTE: making cutout images and setting their pixels to 0 is # ~10x faster than using segment_img directly and ~50% faster # than using ndimage.sum_labels. slices = find_objects(segment_img) segm_labels = [] segm_slices = [] for label, slc in zip(labels, slices, strict=True): cutout = segment_img[slc] segment_mask = (cutout == label) if np.count_nonzero(segment_mask) < npixels: cutout[segment_mask] = 0 continue segm_labels.append(label) segm_slices.append(slc) if np.count_nonzero(segment_img) == 0: return None if relabel: # relabel the segmentation image with consecutive numbers; # ndimage.label returns segment_img with dtype = np.int32 # unless the input array has more than 2**31 - 1 pixels nlabels = len(segm_labels) if len(labels) != nlabels: label_map = np.zeros(np.max(labels) + 1, dtype=segment_img.dtype) labels = np.arange(nlabels, dtype=segment_img.dtype) + 1 label_map[segm_labels] = labels segment_img = label_map[segment_img] else: labels = segm_labels if return_segmimg: segm = object.__new__(SegmentationImage) segm._data = segment_img segm.__dict__['labels'] = labels segm.__dict__['slices'] = segm_slices segm.__dict__['_deblend_label_map'] = {} return segm # this is used by deblend_sources if len(labels) == 1: return None return segment_img def detect_sources(data, threshold, npixels, *, connectivity=8, mask=None): """ Detect sources above a specified threshold value in an image. Detected sources must have ``npixels`` connected pixels that are each greater than the ``threshold`` value in the input ``data``. The input ``mask`` can be used to mask pixels in the input data. Masked pixels will not be included in any source. This function does not deblend overlapping sources. First use this function to detect sources followed by :func:`~photutils.segmentation.deblend_sources` to deblend sources. Alternatively, use the :class:`~photutils.segmentation.SourceFinder` class to detect and deblend sources in a single step. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array of the image. If filtering is desired, please input a convolved image. threshold : float or 2D `~numpy.ndarray` The data value or pixel-wise data values to be used for the detection threshold. If ``data`` is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units as ``data``. A 2D ``threshold`` array must have the same shape as ``data``. npixels : int The minimum number of connected pixels, each greater than ``threshold``, that an object must have to be detected. ``npixels`` must be a positive integer. connectivity : {4, 8}, optional The type of pixel connectivity used in determining how pixels are grouped into a detected source. The options are 4 or 8 (default). 4-connected pixels touch along their edges. 8-connected pixels touch along their edges or corners. mask : 2D bool `~numpy.ndarray`, optional A boolean mask, with the same shape as the input ``data``, where `True` values indicate masked pixels. Masked pixels will not be included in any source. Returns ------- segment_image : `~photutils.segmentation.SegmentationImage` or `None` A 2D segmentation image, with the same shape as ``data``, where sources are marked by different positive integer values. A value of zero is reserved for the background. If no sources are found then `None` is returned. Raises ------ NoDetectionsWarning If no sources are found. See Also -------- :func:`photutils.segmentation.deblend_sources` :class:`photutils.segmentation.SourceFinder` Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from astropy.convolution import convolve from astropy.stats import sigma_clipped_stats from astropy.visualization import simple_norm from photutils.datasets import make_100gaussians_image from photutils.segmentation import (detect_sources, make_2dgaussian_kernel) # make a simulated image data = make_100gaussians_image() # use sigma-clipped statistics to (roughly) estimate the background # background noise levels mean, _, std = sigma_clipped_stats(data) # subtract the background data -= mean # detect the sources threshold = 3. * std kernel = make_2dgaussian_kernel(3.0, size=3) # FWHM = 3. convolved_data = convolve(data, kernel) segm = detect_sources(convolved_data, threshold, npixels=5) # plot the image and the segmentation image fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 10)) norm = simple_norm(data, 'sqrt', percent=99.) ax1.imshow(data, origin='lower', interpolation='nearest', norm=norm) ax2.imshow(segm.data, origin='lower', interpolation='nearest', cmap=segm.make_cmap(seed=1234)) plt.tight_layout() """ _ = process_quantities((data, threshold), ('data', 'threshold')) if (npixels <= 0) or (int(npixels) != npixels): raise ValueError('npixels must be a positive integer, got ' f'"{npixels}"') if mask is not None: if mask.shape != data.shape: raise ValueError('mask must have the same shape as the input ' 'image.') if mask.all(): raise ValueError('mask must not be True for every pixel. There ' 'are no unmasked pixels in the image to detect ' 'sources.') inverse_mask = np.logical_not(mask) else: inverse_mask = None footprint = _make_binary_structure(data.ndim, connectivity) segm = _detect_sources(data, threshold, npixels, footprint, inverse_mask, relabel=True, return_segmimg=True) if segm is None: warnings.warn('No sources were found.', NoDetectionsWarning) return segm ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/segmentation/finder.py0000644000175100001660000002320314755160622021532 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for detecting sources in an image. """ from photutils.segmentation.deblend import deblend_sources from photutils.segmentation.detect import detect_sources from photutils.utils._parameters import as_pair from photutils.utils._repr import make_repr __all__ = ['SourceFinder'] class SourceFinder: """ Class to detect sources, including deblending, in an image using segmentation. This is a convenience class that combines the functionality of `~photutils.segmentation.detect_sources` and `~photutils.segmentation.deblend_sources`. Sources are deblended using a combination of multi-thresholding and `watershed segmentation `_. In order to deblend sources, there must be a saddle between them. Parameters ---------- npixels : int or array_like of 2 int The minimum number of connected pixels, each greater than a specified threshold, that an object must have to be detected. If ``npixels`` is an integer, then the value will be used for both source detection and deblending (which internally uses source detection at multiple thresholds). If ``npixels`` contains two values, then the first value will be used for source detection and the second value used for source deblending. ``npixels`` values must be positive integers. connectivity : {4, 8}, optional The type of pixel connectivity used in determining how pixels are grouped into a detected source. The options are 4 or 8 (default). 4-connected pixels touch along their edges. 8-connected pixels touch along their edges or corners. deblend : bool, optional Whether to deblend overlapping sources. nlevels : int, optional The number of multi-thresholding levels to use for deblending. Each source will be re-thresholded at ``nlevels`` levels spaced exponentially or linearly (see the ``mode`` keyword) between its minimum and maximum values. This keyword is ignored unless ``deblend=True``. contrast : float, optional The fraction of the total source flux that a local peak must have (at any one of the multi-thresholds) to be deblended as a separate object. ``contrast`` must be between 0 and 1, inclusive. If ``contrast=0`` then every local peak will be made a separate object (maximum deblending). If ``contrast=1`` then no deblending will occur. The default is 0.001, which will deblend sources with a 7.5 magnitude difference. This keyword is ignored unless ``deblend=True``. mode : {'exponential', 'linear', 'sinh'}, optional The mode used in defining the spacing between the multi-thresholding levels (see the ``nlevels`` keyword) during deblending. The ``'exponential'`` and ``'sinh'`` modes have more threshold levels near the source minimum and less near the source maximum. The ``'linear'`` mode evenly spaces the threshold levels between the source minimum and maximum. The ``'exponential'`` and ``'sinh'`` modes differ in that the ``'exponential'`` levels are dependent on the source maximum/minimum ratio (smaller ratios are more linear; larger ratios are more exponential), while the ``'sinh'`` levels are not. Also, the ``'exponential'`` mode will be changed to ``'linear'`` for sources with non-positive minimum data values. This keyword is ignored unless ``deblend=True``. relabel : bool, optional If `True` (default), then the segmentation image will be relabeled after deblending such that the labels are in consecutive order starting from 1. This keyword is ignored unless ``deblend=True``. nproc : int, optional The number of processes to use for source deblending. If set to 1, then a serial implementation is used instead of a parallel one. If `None`, then the number of processes will be set to the number of CPUs detected on the machine. Please note that due to overheads, multiprocessing may be slower than serial processing if only a small number of sources are to be deblended. The benefits of multiprocessing require ~1000 or more sources to deblend, with larger gains as the number of sources increase. This keyword is ignored unless ``deblend=True``. progress_bar : bool, optional Whether to display a progress bar. If ``nproc = 1``, then the ID shown after the progress bar is the source label being deblended. If multiprocessing is used (``nproc > 1``), the ID shown is the last source label that was deblended. The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. This keyword is ignored unless ``deblend=True``. See Also -------- :func:`photutils.segmentation.detect_sources` :func:`photutils.segmentation.deblend_sources` Examples -------- .. plot:: :include-source: import matplotlib.pyplot as plt from astropy.convolution import convolve from astropy.visualization import simple_norm from photutils.background import Background2D, MedianBackground from photutils.datasets import make_100gaussians_image from photutils.segmentation import (SourceFinder, make_2dgaussian_kernel) # make a simulated image data = make_100gaussians_image() # subtract the background bkg_estimator = MedianBackground() bkg = Background2D(data, (50, 50), filter_size=(3, 3), bkg_estimator=bkg_estimator) data -= bkg.background # convolve the data kernel = make_2dgaussian_kernel(3., size=5) # FWHM = 3. convolved_data = convolve(data, kernel) # detect the sources threshold = 1.5 * bkg.background_rms # per-pixel threshold finder = SourceFinder(npixels=10, progress_bar=False) segm = finder(convolved_data, threshold) # plot the image and the segmentation image fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 10)) norm = simple_norm(data, 'sqrt', percent=99.) ax1.imshow(data, origin='lower', interpolation='nearest', norm=norm) ax2.imshow(segm.data, origin='lower', interpolation='nearest', cmap=segm.cmap) plt.tight_layout() """ def __init__(self, npixels, *, connectivity=8, deblend=True, nlevels=32, contrast=0.001, mode='exponential', relabel=True, nproc=1, progress_bar=True): self.npixels = as_pair('npixels', npixels, check_odd=False) self.deblend = deblend self.connectivity = connectivity self.nlevels = nlevels self.contrast = contrast self.mode = mode self.relabel = relabel self.nproc = nproc self.progress_bar = progress_bar def __repr__(self): params = ('npixels', 'deblend', 'connectivity', 'nlevels', 'contrast', 'mode', 'relabel', 'nproc', 'progress_bar') return make_repr(self, params) def __call__(self, data, threshold, mask=None): """ Detect sources, including deblending, in an image using segmentation. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array from which to detect sources. Typically, this array should be an image that has been convolved with a smoothing kernel. threshold : 2D `~numpy.ndarray` or float The data value or pixel-wise data values (as an array) to be used as the per-pixel detection threshold. If ``data`` is a `~astropy.units.Quantity` array, then ``threshold`` must have the same units as ``data``. A 2D ``threshold`` array must have the same shape as ``data``. mask : 2D bool `~numpy.ndarray`, optional A boolean mask with the same shape as ``data``, where a `True` value indicates the corresponding element of ``data`` is masked. Masked pixels will not be included in any source. Returns ------- segment_image : `~photutils.segmentation.SegmentationImage` or `None` A 2D segmentation image, with the same shape as the input data, where sources are marked by different positive integer values. A value of zero is reserved for the background. If no sources are found then `None` is returned. """ segment_img = detect_sources(data, threshold, self.npixels[0], mask=mask, connectivity=self.connectivity) if segment_img is None: return None # source deblending requires scikit-image if self.deblend: segment_img = deblend_sources(data, segment_img, self.npixels[1], nlevels=self.nlevels, contrast=self.contrast, mode=self.mode, connectivity=self.connectivity, relabel=self.relabel, nproc=self.nproc, progress_bar=self.progress_bar) return segment_img ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1739907483.720927 photutils-2.2.0/photutils/segmentation/tests/0000755000175100001660000000000014755160634021056 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/segmentation/tests/__init__.py0000644000175100001660000000000014755160622023152 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/segmentation/tests/test_catalog.py0000644000175100001660000011516614755160622024110 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the catalog module. """ from io import StringIO import astropy.units as u import numpy as np import pytest from astropy.convolution import convolve from astropy.coordinates import SkyCoord from astropy.modeling.models import Gaussian2D from astropy.table import QTable from numpy.testing import assert_allclose, assert_equal from photutils.aperture import (BoundingBox, CircularAperture, EllipticalAperture) from photutils.background import Background2D, MedianBackground from photutils.datasets import (make_100gaussians_image, make_gwcs, make_noise_image, make_wcs) from photutils.segmentation.catalog import SourceCatalog from photutils.segmentation.core import SegmentationImage from photutils.segmentation.detect import detect_sources from photutils.segmentation.finder import SourceFinder from photutils.segmentation.utils import make_2dgaussian_kernel from photutils.utils._optional_deps import (HAS_GWCS, HAS_MATPLOTLIB, HAS_SKIMAGE) from photutils.utils.cutouts import CutoutImage class TestSourceCatalog: def setup_class(self): xcen = 51.0 ycen = 52.7 major_sigma = 8.0 minor_sigma = 3.0 theta = np.pi / 6.0 g1 = Gaussian2D(111.0, xcen, ycen, major_sigma, minor_sigma, theta=theta) g2 = Gaussian2D(50, 20, 80, 5.1, 4.5) g3 = Gaussian2D(70, 75, 18, 9.2, 4.5) g4 = Gaussian2D(111.0, 11.1, 12.2, major_sigma, minor_sigma, theta=theta) g5 = Gaussian2D(81.0, 61, 42.7, major_sigma, minor_sigma, theta=theta) g6 = Gaussian2D(107.0, 75, 61, major_sigma, minor_sigma, theta=-theta) g7 = Gaussian2D(107.0, 90, 90, 4, 2, theta=-theta) yy, xx = np.mgrid[0:101, 0:101] self.data = (g1(xx, yy) + g2(xx, yy) + g3(xx, yy) + g4(xx, yy) + g5(xx, yy) + g6(xx, yy) + g7(xx, yy)) threshold = 27.0 self.segm = detect_sources(self.data, threshold, npixels=5) self.error = make_noise_image(self.data.shape, mean=0, stddev=2.0, seed=123) self.background = np.ones(self.data.shape) * 5.1 self.mask = np.zeros(self.data.shape, dtype=bool) self.mask[0:30, 0:30] = True self.wcs = make_wcs(self.data.shape) self.cat = SourceCatalog(self.data, self.segm, error=self.error, background=self.background, mask=self.mask, wcs=self.wcs, localbkg_width=24) unit = u.nJy self.unit = unit self.cat_units = SourceCatalog(self.data << unit, self.segm, error=self.error << unit, background=self.background << unit, mask=self.mask, wcs=self.wcs, localbkg_width=24) @pytest.mark.parametrize('with_units', [True, False]) def test_catalog(self, with_units): if with_units: cat1 = self.cat_units.copy() cat2 = self.cat_units.copy() else: cat1 = self.cat.copy() cat2 = self.cat.copy() props = self.cat.properties # add extra properties cat1.circular_photometry(5.0, name='circ5') cat1.kron_photometry((2.5, 1.4), name='kron2') cat1.fluxfrac_radius(0.5, name='r_hl') segment_snr = cat1.segment_flux / cat1.segment_fluxerr cat1.add_extra_property('segment_snr', segment_snr) props = list(props) props.extend(cat1.extra_properties) idx = 1 # no NaN values # evaluate (cache) catalog properties before slice obj = cat1[idx] for prop in props: assert_equal(getattr(cat1, prop)[idx], getattr(obj, prop)) # slice catalog before evaluating catalog properties obj = cat2[idx] obj.circular_photometry(5.0, name='circ5') obj.kron_photometry((2.5, 1.4), name='kron2') obj.fluxfrac_radius(0.5, name='r_hl') segment_snr = obj.segment_flux / obj.segment_fluxerr obj.add_extra_property('segment_snr', segment_snr) for prop in props: assert_equal(getattr(obj, prop), getattr(cat1, prop)[idx]) match = 'Both units and masked cannot be True' with pytest.raises(ValueError, match=match): cat1._prepare_cutouts(cat1._segment_img_cutouts, units=True, masked=True) @pytest.mark.parametrize('with_units', [True, False]) def test_catalog_detection_cat(self, with_units): """ Test aperture-based properties with an input detection catalog. """ error = 2.0 * self.error data2 = self.data + error if with_units: cat1 = self.cat_units.copy() cat2 = SourceCatalog(data2 << self.unit, self.segm, error=error << self.unit, background=self.background << self.unit, mask=self.mask, wcs=self.wcs, localbkg_width=24, detection_cat=None) cat3 = SourceCatalog(data2 << self.unit, self.segm, error=error << self.unit, background=self.background << self.unit, mask=self.mask, wcs=self.wcs, localbkg_width=24, detection_cat=cat1) else: cat1 = self.cat.copy() cat2 = SourceCatalog(data2, self.segm, error=error, background=self.background, mask=self.mask, wcs=self.wcs, localbkg_width=24, detection_cat=None) cat3 = SourceCatalog(data2, self.segm, error=error, background=self.background, mask=self.mask, wcs=self.wcs, localbkg_width=24, detection_cat=cat1) assert_equal(cat1.kron_radius, cat3.kron_radius) # assert not equal 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_fluxerr, cat3.kron_fluxerr) with pytest.raises(AssertionError, match=match): assert_equal(cat1.kron_flux, cat3.kron_flux) with pytest.raises(AssertionError, match=match): assert_equal(cat1.kron_fluxerr, cat3.kron_fluxerr) flux1, fluxerr1 = cat1.circular_photometry(1.0) flux2, fluxerr2 = cat2.circular_photometry(1.0) flux3, fluxerr3 = cat3.circular_photometry(1.0) with pytest.raises(AssertionError, match=match): assert_equal(flux2, flux3) with pytest.raises(AssertionError, match=match): assert_equal(fluxerr2, fluxerr3) with pytest.raises(AssertionError, match=match): assert_equal(flux1, flux2) with pytest.raises(AssertionError, match=match): assert_equal(fluxerr1, fluxerr2) flux1, fluxerr1 = cat1.kron_photometry((2.5, 1.4)) flux2, fluxerr2 = cat2.kron_photometry((2.5, 1.4)) flux3, fluxerr3 = cat3.kron_photometry((2.5, 1.4)) with pytest.raises(AssertionError, match=match): assert_equal(flux2, flux3) with pytest.raises(AssertionError, match=match): assert_equal(fluxerr2, fluxerr3) with pytest.raises(AssertionError, match=match): assert_equal(flux1, flux2) with pytest.raises(AssertionError, match=match): assert_equal(fluxerr1, fluxerr2) radius1 = cat1.fluxfrac_radius(0.5) radius2 = cat2.fluxfrac_radius(0.5) radius3 = cat3.fluxfrac_radius(0.5) with pytest.raises(AssertionError, 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): cat = SourceCatalog(self.data, self.segm) obj = cat[4] props = ('background', 'background_ma', 'error', 'error_ma') for prop in props: assert getattr(obj, prop) is None arr_props = ('_background_cutouts', '_error_cutouts') for prop in arr_props: assert getattr(obj, prop)[0] is None props = ('background_mean', 'background_sum', 'background_centroid', 'segment_fluxerr', 'kron_fluxerr') for prop in props: assert np.isnan(getattr(obj, prop)) assert obj.local_background_aperture is None assert obj.local_background == 0.0 def test_slicing(self): self.cat.to_table() # evaluate and cache several properties obj1 = self.cat[0] assert obj1.nlabels == 1 obj1b = self.cat.get_label(1) assert obj1b.nlabels == 1 obj2 = self.cat[0:1] assert obj2.nlabels == 1 assert len(obj2) == 1 obj3 = self.cat[0:3] obj3b = self.cat.get_labels((1, 2, 3)) assert_equal(obj3.label, obj3b.label) obj4 = self.cat[[0, 1, 2]] assert obj3.nlabels == 3 assert obj3b.nlabels == 3 assert obj4.nlabels == 3 assert len(obj3) == 3 assert len(obj4) == 3 obj5 = self.cat[[3, 2, 1]] labels = [4, 3, 2] obj5b = self.cat.get_labels(labels) assert_equal(obj5.label, obj5b.label) assert obj5.nlabels == 3 assert len(obj5) == 3 assert_equal(obj5.label, labels) # test get_labels when labels are not sorted obj5 = self.cat[[3, 2, 1]] labels2 = (3, 4) obj5b = obj5.get_labels(labels2) assert_equal(obj5b.label, labels2) obj6 = obj5[0] assert obj6.label == labels[0] mask = self.cat.label > 3 obj7 = self.cat[mask] assert obj7.nlabels == 4 assert len(obj7) == 4 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.get_label(1000) with pytest.raises(ValueError, match=match): self.cat.get_labels([1, 2, 1000]) def test_iter(self): labels = [obj.label for obj in self.cat] assert len(labels) == len(self.cat) def test_table(self): columns = ['label', 'xcentroid', 'ycentroid'] tbl = self.cat.to_table(columns=columns) assert len(tbl) == 7 assert tbl.colnames == columns tbl = self.cat.to_table(self.cat.default_columns) for col in tbl.columns: assert isinstance(col, str) assert not isinstance(col, np.str_) tbl = self.cat.to_table('label') for col in tbl.columns: assert isinstance(col, str) assert not isinstance(col, np.str_) def test_invalid_inputs(self): segm = SegmentationImage(np.zeros(self.data.shape, dtype=int)) match = 'segment_img must have at least one non-zero label' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, segm) # test 1D arrays img1d = np.arange(4) segm = SegmentationImage(img1d) match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): SourceCatalog(img1d, segm) wrong_shape = np.ones((3, 3), dtype=int) match = 'segment_img and data must have the same shape' with pytest.raises(ValueError, match=match): SourceCatalog(wrong_shape, self.segm) match = 'data and error must have the same shape' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, error=wrong_shape) match = 'data and background must have the same shape' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, background=wrong_shape) match = 'data and mask must have the same shape' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, mask=wrong_shape) segm = SegmentationImage(wrong_shape) match = 'segment_img and data must have the same shape' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, segm) match = 'segment_img 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 = 'localbkg_width must be >= 0' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, localbkg_width=-1) match = 'localbkg_width must be an integer' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, localbkg_width=3.4) apermask_method = 'invalid' match = 'Invalid apermask_method value' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, apermask_method=apermask_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): 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): mywcs = make_wcs(self.data.shape) cat = SourceCatalog(self.data, self.segm, wcs=mywcs) obj = cat[0] assert obj.sky_centroid is not None assert obj.sky_centroid_icrs is not None assert obj.sky_centroid_win is not None assert obj.sky_bbox_ll is not None assert obj.sky_bbox_ul is not None assert obj.sky_bbox_lr is not None assert obj.sky_bbox_ur is not None @pytest.mark.skipif(not HAS_GWCS, reason='gwcs is required') def test_gwcs(self): mywcs = make_gwcs(self.data.shape) cat = SourceCatalog(self.data, self.segm, wcs=mywcs) obj = cat[1] assert obj.sky_centroid is not None assert obj.sky_centroid_icrs is not None assert obj.sky_centroid_win is not None assert obj.sky_bbox_ll is not None assert obj.sky_bbox_ul is not None assert obj.sky_bbox_lr is not None assert obj.sky_bbox_ur is not None def test_nowcs(self): cat = SourceCatalog(self.data, self.segm, wcs=None) obj = cat[2] assert obj.sky_centroid is None assert obj.sky_centroid_icrs is None assert obj.sky_centroid_win is None assert obj.sky_bbox_ll is None assert obj.sky_bbox_ul is None assert obj.sky_bbox_lr is None assert obj.sky_bbox_ur is None def test_to_table(self): cat = SourceCatalog(self.data, self.segm) assert len(cat) == 7 tbl = cat.to_table() assert isinstance(tbl, QTable) assert len(tbl) == 7 obj = cat[0] assert obj.nlabels == 1 tbl = obj.to_table() assert len(tbl) == 1 def test_masks(self): """ Test masks, including automatic masking of all non-finite (e.g., NaN, inf) values in the data array. """ data = np.copy(self.data) error = np.copy(self.error) background = np.copy(self.background) data[:, 55] = np.nan data[16, :] = np.inf error[:, 55] = np.nan error[16, :] = np.inf background[:, 55] = np.nan background[16, :] = np.inf cat = SourceCatalog(data, self.segm, error=error, background=background, mask=self.mask) props = ('xcentroid', 'ycentroid', 'area', 'orientation', 'segment_flux', 'segment_fluxerr', 'kron_flux', 'kron_fluxerr', 'background_mean') obj = cat[0] for prop in props: assert np.isnan(getattr(obj, prop)) objs = cat[1:] for prop in props: assert np.all(np.isfinite(getattr(objs, prop))) # test that mask=None is the same as mask=np.ma.nomask cat1 = SourceCatalog(data, self.segm, mask=None) cat2 = SourceCatalog(data, self.segm, mask=np.ma.nomask) assert cat1[0].xcentroid == cat2[0].xcentroid def test_repr_str(self): cat = SourceCatalog(self.data, self.segm) assert repr(cat) == str(cat) lines = ('Length: 7', 'labels: [1 2 3 4 5 6 7]') for line in lines: assert line in repr(cat) def test_detection_cat(self): data2 = self.data - 5 cat1 = SourceCatalog(data2, self.segm) cat2 = SourceCatalog(data2, self.segm, detection_cat=self.cat) assert len(cat2.kron_aperture) == len(cat2) assert not np.array_equal(cat1.kron_radius, cat2.kron_radius) assert not np.array_equal(cat1.kron_flux, cat2.kron_flux) assert_allclose(cat2.kron_radius, self.cat.kron_radius) assert not np.array_equal(cat2.kron_flux, self.cat.kron_flux) with pytest.raises(TypeError): SourceCatalog(data2, self.segm, detection_cat=np.arange(4)) segm = self.segm.copy() segm.remove_labels((6, 7)) cat = SourceCatalog(self.data, segm) match = 'detection_cat must have same segment_img as the input' with pytest.raises(ValueError, match=match): SourceCatalog(self.data, self.segm, detection_cat=cat) def test_kron_minradius(self): kron_params = (2.5, 2.5) cat = SourceCatalog(self.data, self.segm, mask=self.mask, apermask_method='none', kron_params=kron_params) assert cat.kron_aperture[0] is None assert np.isnan(cat.kron_radius[0]) kronrad = cat.kron_radius.value kronrad = kronrad[~np.isnan(kronrad)] assert np.min(kronrad) == kron_params[1] assert isinstance(cat.kron_aperture[2], EllipticalAperture) assert isinstance(cat.kron_aperture[4], EllipticalAperture) assert isinstance(cat.kron_params, tuple) def test_kron_masking(self): apermask_method = 'none' cat1 = SourceCatalog(self.data, self.segm, apermask_method=apermask_method) apermask_method = 'mask' cat2 = SourceCatalog(self.data, self.segm, apermask_method=apermask_method) apermask_method = 'correct' cat3 = SourceCatalog(self.data, self.segm, apermask_method=apermask_method) idx = 2 # source with close neighbors assert cat1[idx].kron_flux > cat2[idx].kron_flux assert cat3[idx].kron_flux > cat2[idx].kron_flux assert cat1[idx].kron_flux > cat3[idx].kron_flux def test_kron_negative(self): cat = SourceCatalog(self.data - 10, self.segm) assert_allclose(cat.kron_radius.value, cat.kron_params[1]) def test_kron_photometry(self): flux0, fluxerr0 = self.cat.kron_photometry((2.5, 1.4)) assert_allclose(flux0, self.cat.kron_flux) assert_allclose(fluxerr0, self.cat.kron_fluxerr) flux1, fluxerr1 = self.cat.kron_photometry((1.0, 1.4), name='kron1') flux2, fluxerr2 = self.cat.kron_photometry((2.0, 1.4), name='kron2') assert_allclose(flux1, self.cat.kron1_flux) assert_allclose(fluxerr1, self.cat.kron1_fluxerr) assert_allclose(flux2, self.cat.kron2_flux) assert_allclose(fluxerr2, self.cat.kron2_fluxerr) assert np.all((flux2 > flux1) | (np.isnan(flux2) & np.isnan(flux1))) assert np.all((fluxerr2 > fluxerr1) | (np.isnan(fluxerr2) & np.isnan(fluxerr1))) # test different min Kron radius flux3, fluxerr3 = self.cat.kron_photometry((2.5, 2.5)) assert np.all((flux3 > flux0) | (np.isnan(flux3) & np.isnan(flux0))) assert np.all((fluxerr3 > fluxerr0) | (np.isnan(fluxerr3) & np.isnan(fluxerr0))) obj = self.cat[1] flux1, fluxerr1 = obj.kron_photometry((1.0, 1.4), name='kron0') assert np.isscalar(flux1) assert np.isscalar(fluxerr1) assert_allclose(flux1, obj.kron0_flux) assert_allclose(fluxerr1, obj.kron0_fluxerr) cat = SourceCatalog(self.data, self.segm) _, fluxerr = cat.kron_photometry((2.0, 1.4)) assert np.all(np.isnan(fluxerr)) 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): flux1, fluxerr1 = self.cat.circular_photometry(1.0, name='circ1') flux2, fluxerr2 = self.cat.circular_photometry(5.0, name='circ5') assert_allclose(flux1, self.cat.circ1_flux) assert_allclose(fluxerr1, self.cat.circ1_fluxerr) assert_allclose(flux2, self.cat.circ5_flux) assert_allclose(fluxerr2, self.cat.circ5_fluxerr) assert np.all((flux2 > flux1) | (np.isnan(flux2) & np.isnan(flux1))) assert np.all((fluxerr2 > fluxerr1) | (np.isnan(fluxerr2) & np.isnan(fluxerr1))) obj = self.cat[1] assert obj.isscalar flux1, fluxerr1 = obj.circular_photometry(1.0, name='circ0') assert np.isscalar(flux1) assert np.isscalar(fluxerr1) assert_allclose(flux1, obj.circ0_flux) assert_allclose(fluxerr1, obj.circ0_fluxerr) cat = SourceCatalog(self.data, self.segm) _, fluxerr = cat.circular_photometry(1.0) assert np.all(np.isnan(fluxerr)) # with "center" mode, tiny apertures that do not overlap any # center should return NaN cat2 = self.cat.copy() cat2._set_semode() # sets "center" mode flux1, fluxerr1 = cat2.circular_photometry(0.1) assert np.all(np.isnan(flux1[2:4])) assert np.all(np.isnan(fluxerr1[2:4])) 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): from matplotlib.patches import Patch patches = self.cat.plot_circular_apertures(5.0) assert isinstance(patches, list) for patch in patches: assert isinstance(patch, Patch) patches = self.cat.plot_kron_apertures() assert isinstance(patches, list) for patch in patches: assert isinstance(patch, Patch) patches2 = self.cat.plot_kron_apertures((2.0, 1.2)) assert isinstance(patches2, list) for patch in patches2: assert isinstance(patch, Patch) # test scalar obj = self.cat[1] patch1 = obj.plot_kron_apertures() assert isinstance(patch1, Patch) patch2 = obj.plot_kron_apertures((2.0, 1.2)) assert isinstance(patch2, Patch) def test_fluxfrac_radius(self): radius1 = self.cat.fluxfrac_radius(0.1, name='fluxfrac_r1') radius2 = self.cat.fluxfrac_radius(0.5, name='fluxfrac_r5') assert_allclose(radius1, self.cat.fluxfrac_r1) assert_allclose(radius2, self.cat.fluxfrac_r5) assert np.all((radius2 > radius1) | (np.isnan(radius2) & np.isnan(radius1))) cat = SourceCatalog(self.data, self.segm) obj = cat[1] radius = obj.fluxfrac_radius(0.5) assert radius.isscalar # Quantity radius - can't use np.isscalar assert_allclose(radius.value, 7.899648) match = 'fluxfrac must be > 0 and <= 1' with pytest.raises(ValueError, match=match): radius = self.cat.fluxfrac_radius(0) with pytest.raises(ValueError, match=match): radius = self.cat.fluxfrac_radius(-1) cat = SourceCatalog(self.data - 50.0, self.segm, error=self.error, background=self.background, mask=self.mask, wcs=self.wcs, localbkg_width=24) radius_hl = cat.fluxfrac_radius(0.5) assert np.isnan(radius_hl[0]) def test_cutout_units(self): obj = self.cat_units[0] quantities = (obj.data, obj.error, obj.background) ndarray = (obj.segment, obj.segment_ma, obj.data_ma, obj.error_ma, obj.background_ma) for arr in quantities: assert isinstance(arr, u.Quantity) for arr in ndarray: assert not isinstance(arr, u.Quantity) @pytest.mark.parametrize('scalar', [True, False]) def test_extra_properties(self, scalar): cat = SourceCatalog(self.data, self.segm) if scalar: cat = cat[1] segment_snr = cat.segment_flux / cat.segment_fluxerr match = 'cannot be set because it is a built-in attribute' with pytest.raises(ValueError, match=match): # built-in attribute cat.add_extra_property('_data', segment_snr) with pytest.raises(ValueError, match=match): # built-in property cat.add_extra_property('label', segment_snr) with pytest.raises(ValueError, match=match): # built-in lazyproperty cat.add_extra_property('area', segment_snr) cat.add_extra_property('segment_snr', segment_snr) match = 'already exists as an attribute' with pytest.raises(ValueError, match=match): # already exists cat.add_extra_property('segment_snr', segment_snr) cat.add_extra_property('segment_snr', 2.0 * segment_snr, overwrite=True) assert len(cat.extra_properties) == 1 assert_equal(cat.segment_snr, 2.0 * segment_snr) match = 'is not a defined extra property' with pytest.raises(ValueError, match=match): cat.remove_extra_property('invalid') cat.remove_extra_property(cat.extra_properties) assert len(cat.extra_properties) == 0 cat.add_extra_property('segment_snr', segment_snr) cat.add_extra_property('segment_snr2', segment_snr) cat.add_extra_property('segment_snr3', segment_snr) assert len(cat.extra_properties) == 3 cat.remove_extra_properties(cat.extra_properties) assert len(cat.extra_properties) == 0 cat.add_extra_property('segment_snr', segment_snr) new_name = 'segment_snr0' cat.rename_extra_property('segment_snr', new_name) assert new_name in cat.extra_properties # key in extra_properties, but not a defined attribute cat._extra_properties.append('invalid') match = 'already exists in the "extra_properties" attribute' with pytest.raises(ValueError, match=match): cat.add_extra_property('invalid', segment_snr) cat._extra_properties.remove('invalid') assert cat._has_len([1, 2, 3]) assert not cat._has_len('test_string') def test_extra_properties_invalid(self): cat = SourceCatalog(self.data, self.segm) match = 'value must have the same number of elements as the catalog' with pytest.raises(ValueError, match=match): cat.add_extra_property('invalid', 1.0) with pytest.raises(ValueError, match=match): cat.add_extra_property('invalid', (1.0, 2.0)) obj = cat[1] with pytest.raises(ValueError, match=match): obj.add_extra_property('invalid', (1.0, 2.0)) val = np.arange(2) << u.km with pytest.raises(ValueError, match=match): obj.add_extra_property('invalid', val) coord = SkyCoord([42, 43], [44, 45], unit='deg') with pytest.raises(ValueError, match=match): obj.add_extra_property('invalid', coord) def test_properties(self): attrs = ('label', 'labels', 'slices', 'xcentroid', 'segment_flux', 'kron_flux') for attr in attrs: assert attr in self.cat.properties def test_copy(self): cat = SourceCatalog(self.data, self.segm) cat2 = cat.copy() _ = cat.kron_flux assert 'kron_flux' not in cat2.__dict__ tbl = cat2.to_table() assert len(tbl) == 7 def test_data_dtype(self): """ Regression test that input ``data`` with int dtype does not raise UFuncTypeError due to subtraction of float array from int array. """ data = np.zeros((25, 25), dtype=np.uint16) data[8:16, 8:16] = 10 segmdata = np.zeros((25, 25), dtype=int) segmdata[8:16, 8:16] = 1 segm = SegmentationImage(segmdata) cat = SourceCatalog(data, segm, localbkg_width=3) assert cat.min_value == 10 assert cat.max_value == 10 def test_make_circular_apertures(self): radius = 10 aper = self.cat.make_circular_apertures(radius) assert len(aper) == len(self.cat) assert isinstance(aper[1], CircularAperture) assert aper[1].r == radius obj = self.cat[1] aper = obj.make_circular_apertures(radius) assert isinstance(aper, CircularAperture) assert aper.r == radius def test_make_kron_apertures(self): aper = self.cat.make_kron_apertures() assert len(aper) == len(self.cat) assert isinstance(aper[1], EllipticalAperture) aper2 = self.cat.make_kron_apertures((2.0, 1.4)) assert len(aper2) == len(self.cat) obj = self.cat[1] aper = obj.make_kron_apertures() assert isinstance(aper, EllipticalAperture) @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_make_cutouts(self): data = make_100gaussians_image() bkg_estimator = MedianBackground() bkg = Background2D(data, (50, 50), filter_size=(3, 3), bkg_estimator=bkg_estimator) data -= bkg.background # subtract the background threshold = 1.5 * bkg.background_rms kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) npixels = 10 finder = SourceFinder(npixels=npixels, progress_bar=False) segment_map = finder(convolved_data, threshold) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data) shape = (100, 100) 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) assert cutouts[1].data.shape == shape obj = cat[1] cut = obj.make_cutouts(shape) assert isinstance(cut, CutoutImage) assert cut.data.shape == shape cutouts = cat.make_cutouts(shape, mode='partial', fill_value=-100) assert cutouts[0].data[0, 0] == -100 # cutout will be None if source is completely masked cutouts = self.cat.make_cutouts(shape) assert cutouts[0] is None def test_meta(self): meta = self.cat.meta attrs = ['localbkg_width', 'apermask_method', 'kron_params'] for attr in attrs: assert attr in meta tbl = self.cat.to_table() assert tbl.meta == self.cat.meta out = StringIO() tbl.write(out, format='ascii.ecsv') tbl2 = QTable.read(out.getvalue(), format='ascii.ecsv') # check order of meta keys assert list(tbl2.meta.keys()) == list(tbl.meta.keys()) def test_semode(self): self.cat._set_semode() tbl = self.cat.to_table() assert len(tbl) == 7 def test_tiny_sources(self): data = np.zeros((11, 11)) data[5, 5] = 1.0 data[8, 8] = 1.0 segm = detect_sources(data, 0.1, 1) data[8, 8] = 0 cat = SourceCatalog(data, segm) assert_allclose(cat[0].covariance, [(1 / 12, 0), (0, 1 / 12)] * u.pix**2) assert_allclose(cat[1].covariance, [(np.nan, np.nan), (np.nan, np.nan)] * u.pix**2) assert_allclose(cat.fwhm, [0.67977799, np.nan] * u.pix) @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_kron_params(): data = make_100gaussians_image() bkg_estimator = MedianBackground() bkg = Background2D(data, (50, 50), filter_size=(3, 3), bkg_estimator=bkg_estimator) data -= bkg.background # subtract the background threshold = 1.5 * bkg.background_rms kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) npixels = 10 finder = SourceFinder(npixels=npixels, progress_bar=False) segm = finder(convolved_data, threshold) minrad = 1.4 kron_params = (2.5, minrad, 0.0) cat = SourceCatalog(data, segm, convolved_data=convolved_data, kron_params=kron_params) assert cat.kron_radius.value.min() == minrad assert_allclose(cat.kron_flux.min(), 264.775307) rh = cat.fluxfrac_radius(0.5) assert_allclose(rh.value.min(), 1.293722, rtol=1e-6) minrad = 1.2 kron_params = (2.5, minrad, 0.0) cat = SourceCatalog(data, segm, convolved_data=convolved_data, kron_params=kron_params) assert cat.kron_radius.value.min() == minrad assert_allclose(cat.kron_flux.min(), 264.775307) rh = cat.fluxfrac_radius(0.5) assert_allclose(rh.value.min(), 1.312618, rtol=1e-6) minrad = 0.2 kron_params = (2.5, minrad, 0.0) cat = SourceCatalog(data, segm, convolved_data=convolved_data, kron_params=kron_params) assert_allclose(cat.kron_radius.value.min(), 0.677399, rtol=1e-6) assert_allclose(cat.kron_flux.min(), 264.775307) rh = cat.fluxfrac_radius(0.5) assert_allclose(rh.value.min(), 1.232554) kron_params = (2.5, 1.4, 7.0) cat = SourceCatalog(data, segm, convolved_data=convolved_data, kron_params=kron_params) assert cat.kron_radius.value.min() == 0.0 assert_allclose(cat.kron_flux.min(), 264.775307) rh = cat.fluxfrac_radius(0.5) assert_allclose(rh.value.min(), 1.288211, rtol=1e-6) assert isinstance(cat.kron_aperture[0], CircularAperture) @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_centroid_win(): g1 = Gaussian2D(1621, 6.29, 10.95, 1.55, 1.29, 0.296706) g2 = Gaussian2D(3596, 13.81, 8.29, 1.44, 1.27, 0.628319) m = g1 + g2 yy, xx = np.mgrid[0:21, 0:21] data = m(xx, yy) noise = make_noise_image(data.shape, mean=0, stddev=65.0, seed=123) data += noise kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) npixels = 10 finder = SourceFinder(npixels=npixels, progress_bar=False) threshold = 107.9 segment_map = finder(convolved_data, threshold) cat = SourceCatalog(data, segment_map, convolved_data=convolved_data, apermask_method='none') assert cat.xcentroid[0] != cat.xcentroid_win[0] assert cat.ycentroid[0] != cat.ycentroid_win[0] # centroid_win moved beyond 1-sigma ellipse and was reset to # isophotal centroid assert cat.xcentroid[1] == cat.xcentroid_win[1] assert cat.ycentroid[1] == cat.ycentroid_win[1] def test_centroid_win_migrate(): """ Test that when the windowed centroid moves the aperture completely off the image the isophotal centroid is returned. """ g1 = Gaussian2D(1621, 76.29, 185.95, 1.55, 1.29, 0.296706) g2 = Gaussian2D(3596, 83.81, 182.29, 1.44, 1.27, 0.628319) m = g1 + g2 yy, xx = np.mgrid[0:256, 0:256] data = m(xx, yy) noise = make_noise_image(data.shape, mean=0, stddev=65.0, seed=123) data += noise segm = detect_sources(data, 98.0, npixels=5) cat = SourceCatalog(data, segm) indices = (0, 3, 14, 30) for idx in indices: assert_equal(cat.centroid_win[idx], cat.centroid[idx]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/segmentation/tests/test_core.py0000644000175100001660000005675314755160622023434 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the core module. """ import numpy as np import pytest from astropy.utils import lazyproperty from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_equal from photutils.segmentation.core import Segment, SegmentationImage from photutils.utils import circular_footprint from photutils.utils._optional_deps import (HAS_MATPLOTLIB, HAS_RASTERIO, HAS_REGIONS, HAS_SHAPELY) class TestSegmentationImage: def setup_class(self): self.data = np.array([[1, 1, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) self.segm = SegmentationImage(self.data) def test_array(self): assert_allclose(self.segm.data, self.segm.__array__()) def test_copy(self): segm = SegmentationImage(self.data.copy()) segm2 = segm.copy() assert segm.data is not segm2.data assert segm.labels is not segm2.labels segm.data[0, 0] = 100.0 assert segm.data[0, 0] != segm2.data[0, 0] def test_slicing(self): segm2 = self.segm[1:5, 2:5] assert segm2.shape == (4, 3) assert_equal(segm2.labels, [3, 5]) assert segm2.data.sum() == 16 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] with pytest.raises(TypeError, match=match): self.segm[1:1, 2:4] def test_data_all_zeros(self): data = np.zeros((5, 5), dtype=int) segm = SegmentationImage(data) assert segm.max_label == 0 assert not segm.is_consecutive assert segm.cmap is None match = 'segmentation image of all zeros' with pytest.warns(AstropyUserWarning, match=match): segm.relabel_consecutive() def test_data_reassignment(self): segm = SegmentationImage(self.data.copy()) segm.data = self.data[0:3, :].copy() assert_equal(segm.labels, [1, 3, 4]) def test_invalid_data(self): # is float dtype data = np.zeros((3, 3), dtype=float) match = 'data must be have integer type' with pytest.raises(TypeError, match=match): SegmentationImage(data) # contains a negative value data = np.arange(-1, 8).reshape(3, 3).astype(int) match = 'The segmentation image cannot contain negative integers' with pytest.raises(ValueError, match=match): SegmentationImage(data) # is not ndarray data = [[1, 1], [0, 1]] match = 'Input data must be a numpy array' with pytest.raises(TypeError, match=match): SegmentationImage(data) @pytest.mark.parametrize('label', [0, -1, 2]) def test_invalid_label(self, label): # test with scalar labels 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 with array of labels match = 'are invalid' with pytest.raises(ValueError, match=match): self.segm.check_labels([0, -1, 2]) def test_data_ma(self): assert isinstance(self.segm.data_ma, np.ma.MaskedArray) assert np.ma.count(self.segm.data_ma) == 18 assert np.ma.count_masked(self.segm.data_ma) == 18 def test_segments(self): assert isinstance(self.segm.segments[0], Segment) assert_allclose(self.segm.segments[0].data, self.segm.segments[0].__array__()) assert (self.segm.segments[0].data_ma.shape == self.segm.segments[0].data.shape) assert (self.segm.segments[0].data_ma.filled(0.0).sum() == self.segm.segments[0].data.sum()) label = 4 idx = self.segm.get_index(label) assert self.segm.segments[idx].label == label assert self.segm.segments[idx].area == self.segm.areas[idx] assert self.segm.segments[idx].slices == self.segm.slices[idx] assert self.segm.segments[idx].bbox == self.segm.bbox[idx] def test_repr_str(self): assert repr(self.segm) == str(self.segm) props = ['shape', 'nlabels'] for prop in props: assert f'{prop}:' in repr(self.segm) def test_segment_repr_str(self): props = ['label', 'slices', 'area'] for prop in props: assert f'{prop}:' in repr(self.segm.segments[0]) def test_segment_data(self): assert_allclose(self.segm.segments[3].data.shape, (3, 3)) assert_allclose(np.unique(self.segm.segments[3].data), [0, 5]) def test_segment_make_cutout(self): cutout = self.segm.segments[3].make_cutout(self.data, masked_array=False) assert not np.ma.is_masked(cutout) assert_allclose(cutout.shape, (3, 3)) cutout = self.segm.segments[3].make_cutout(self.data, masked_array=True) assert np.ma.is_masked(cutout) assert_allclose(cutout.shape, (3, 3)) def test_segment_make_cutout_input(self): 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_labels(self): assert_allclose(self.segm.labels, [1, 3, 4, 5, 7]) def test_nlabels(self): assert self.segm.nlabels == 5 def test_max_label(self): assert self.segm.max_label == 7 def test_areas(self): expected = np.array([2, 2, 3, 6, 5]) assert_allclose(self.segm.areas, expected) assert (self.segm.get_area(1) == self.segm.areas[self.segm.get_index(1)]) assert_allclose(self.segm.get_areas(self.segm.labels), self.segm.areas) def test_background_area(self): assert self.segm.background_area == 18 def test_is_consecutive(self): assert not self.segm.is_consecutive data = np.array([[2, 2, 0], [0, 3, 3], [0, 0, 4]], 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): assert_allclose(self.segm.missing_labels, [2, 6]) def test_check_labels(self): 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]) def test_bbox_1d(self): segm = SegmentationImage(np.array([0, 0, 1, 1, 0, 2, 2, 0])) match = 'The "bbox" attribute requires a 2D segmentation image' with pytest.raises(ValueError, match=match): _ = segm.bbox @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_reset_cmap(self): segm = self.segm.copy() cmap = segm.cmap.copy() segm.reset_cmap(seed=123) assert not np.array_equal(cmap.colors, segm.cmap.colors) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_make_cmap(self): cmap = self.segm.make_cmap() assert len(cmap.colors) == (self.segm.max_label + 1) assert_allclose(cmap.colors[0], [0, 0, 0, 1]) assert_allclose(self.segm.cmap.colors, self.segm.make_cmap(background_color='#000000ff', seed=0).colors) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') @pytest.mark.parametrize(('color', 'alpha'), [('#00000000', 0.0), ('#00000040', 64 / 255), ('#00000080', 128 / 255), ('#000000C0', 192 / 255), ('#000000FF', 1.0)]) def test_make_cmap_alpha(self, color, alpha): cmap = self.segm.make_cmap(background_color=color) assert_allclose(cmap.colors[0], (0, 0, 0, alpha)) def test_reassign_labels(self): segm = SegmentationImage(self.data.copy()) segm.reassign_labels(labels=[1, 7], new_label=2) ref_data = np.array([[2, 2, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [2, 0, 0, 0, 0, 5], [2, 2, 0, 5, 5, 5], [2, 2, 0, 0, 5, 5]]) assert_allclose(segm.data, ref_data) assert segm.nlabels == len(segm.slices) - segm.slices.count(None) @pytest.mark.parametrize('start_label', [1, 5]) def test_relabel_consecutive(self, start_label): segm = SegmentationImage(self.data.copy()) ref_data = np.array([[1, 1, 0, 0, 3, 3], [0, 0, 0, 0, 0, 3], [0, 0, 2, 2, 0, 0], [5, 0, 0, 0, 0, 4], [5, 5, 0, 4, 4, 4], [5, 5, 0, 0, 4, 4]]) ref_data[ref_data != 0] += (start_label - 1) segm.relabel_consecutive(start_label=start_label) assert_allclose(segm.data, ref_data) # relabel_consecutive should do nothing if already consecutive segm.relabel_consecutive(start_label=start_label) assert_allclose(segm.data, ref_data) assert segm.nlabels == len(segm.slices) - segm.slices.count(None) # test slices caching segm = SegmentationImage(self.data.copy()) slc1 = segm.slices segm.relabel_consecutive() assert slc1 == segm.slices @pytest.mark.parametrize('start_label', [0, -1]) def test_relabel_consecutive_start_invalid(self, start_label): 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): ref_data = np.array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 3, 3, 0, 0], [0, 0, 0, 0, 0, 5], [0, 0, 0, 5, 5, 5], [0, 0, 0, 0, 5, 5]]) segm = SegmentationImage(self.data.copy()) segm.keep_labels([5, 3]) assert_allclose(segm.data, ref_data) def test_keep_labels_relabel(self): ref_data = np.array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 1, 1, 0, 0], [0, 0, 0, 0, 0, 2], [0, 0, 0, 2, 2, 2], [0, 0, 0, 0, 2, 2]]) segm = SegmentationImage(self.data.copy()) segm.keep_labels([5, 3], relabel=True) assert_allclose(segm.data, ref_data) def test_remove_labels(self): ref_data = np.array([[1, 1, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 0, 0, 0, 0], [7, 0, 0, 0, 0, 0], [7, 7, 0, 0, 0, 0], [7, 7, 0, 0, 0, 0]]) segm = SegmentationImage(self.data.copy()) segm.remove_labels(labels=[5, 3]) assert_allclose(segm.data, ref_data) 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): ref_data = np.array([[1, 1, 0, 0, 2, 2], [0, 0, 0, 0, 0, 2], [0, 0, 0, 0, 0, 0], [3, 0, 0, 0, 0, 0], [3, 3, 0, 0, 0, 0], [3, 3, 0, 0, 0, 0]]) segm = SegmentationImage(self.data.copy()) segm.remove_labels(labels=[5, 3], relabel=True) assert_allclose(segm.data, ref_data) def test_remove_border_labels(self): ref_data = np.array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 3, 3, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0]]) segm = SegmentationImage(self.data.copy()) segm.remove_border_labels(border_width=1) assert_allclose(segm.data, ref_data) def test_remove_border_labels_border_width(self): segm = SegmentationImage(self.data.copy()) match = 'border_width must be smaller than half the array size' with pytest.raises(ValueError, match=match): segm.remove_border_labels(border_width=3) def test_remove_border_labels_no_remaining_segments(self): alt_data = self.data.copy() alt_data[alt_data == 3] = 0 segm = SegmentationImage(alt_data) segm.remove_border_labels(border_width=1, relabel=True) assert segm.nlabels == 0 def test_remove_masked_labels(self): ref_data = np.array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) segm = SegmentationImage(self.data.copy()) mask = np.zeros(segm.data.shape, dtype=bool) mask[0, :] = True segm.remove_masked_labels(mask) assert_allclose(segm.data, ref_data) def test_remove_masked_labels_without_partial_overlap(self): ref_data = np.array([[0, 0, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) segm = SegmentationImage(self.data.copy()) mask = np.zeros(segm.data.shape, dtype=bool) mask[0, :] = True segm.remove_masked_labels(mask, partial_overlap=False) assert_allclose(segm.data, ref_data) def test_remove_masked_segments_mask_shape(self): segm = SegmentationImage(np.ones((5, 5), dtype=int)) mask = np.zeros((3, 3), dtype=bool) match = 'mask must have the same shape as the segmentation array' with pytest.raises(ValueError, match=match): segm.remove_masked_labels(mask) def test_make_source_mask(self): segm_array = np.zeros((7, 7)).astype(int) segm_array[3, 3] = 1 segm = SegmentationImage(segm_array) mask = segm.make_source_mask() assert_equal(mask, segm_array.astype(bool)) mask = segm.make_source_mask(size=3) expected1 = np.array([[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 1, 1, 1, 0, 0], [0, 0, 1, 1, 1, 0, 0], [0, 0, 1, 1, 1, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0]]) assert_equal(mask.astype(int), expected1) mask = segm.make_source_mask(footprint=np.ones((3, 3))) assert_equal(mask.astype(int), expected1) footprint = circular_footprint(radius=3) mask = segm.make_source_mask(footprint=footprint) expected2 = np.array([[0, 0, 0, 1, 0, 0, 0], [0, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 0], [1, 1, 1, 1, 1, 1, 1], [0, 1, 1, 1, 1, 1, 0], [0, 1, 1, 1, 1, 1, 0], [0, 0, 0, 1, 0, 0, 0]]) assert_equal(mask.astype(int), expected2) mask = segm.make_source_mask(footprint=np.ones((3, 3)), size=5) assert_equal(mask, expected1) @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_imshow(self): from matplotlib.image import AxesImage axim = self.segm.imshow(figsize=(5, 5)) assert isinstance(axim, AxesImage) axim, cbar = self.segm.imshow_map(figsize=(5, 5)) assert isinstance(axim, AxesImage) @pytest.mark.skipif(not HAS_RASTERIO, reason='rasterio is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') def test_polygons(self): from shapely.geometry.polygon import Polygon polygons = self.segm.polygons assert len(polygons) == self.segm.nlabels assert isinstance(polygons[0], Polygon) data = np.zeros((5, 5), dtype=int) data[2, 2] = 10 segm = SegmentationImage(data) polygons = segm.polygons assert len(polygons) == 1 verts = np.array(polygons[0].exterior.coords) expected_verts = np.array([[1.5, 1.5], [1.5, 2.5], [2.5, 2.5], [2.5, 1.5], [1.5, 1.5]]) assert_equal(verts, expected_verts) @pytest.mark.skipif(not HAS_RASTERIO, reason='rasterio is required') @pytest.mark.skipif(not HAS_SHAPELY, reason='shapely is required') def test_polygon_hole(self): 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): from regions import PolygonPixelRegion, Regions regions = self.segm.to_regions() assert isinstance(regions, Regions) assert isinstance(regions[0], PolygonPixelRegion) assert len(regions) == self.segm.nlabels 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): from matplotlib.patches import Polygon patches = self.segm.to_patches(edgecolor='blue') assert isinstance(patches[0], Polygon) assert patches[0].get_edgecolor() == (0, 0, 1, 1) scale = 2.0 patches2 = self.segm.to_patches(scale=scale) v1 = patches[0].get_verts() v2 = patches2[0].get_verts() v3 = scale * (v1 + 0.5) - 0.5 assert_allclose(v2, v3) patches = self.segm.plot_patches(edgecolor='red') assert isinstance(patches[0], Polygon) assert patches[0].get_edgecolor() == (1, 0, 0, 1) patches = self.segm.plot_patches(labels=1) assert len(patches) == 1 assert isinstance(patches, list) assert isinstance(patches[0], Polygon) patches = self.segm.plot_patches(labels=(4, 7)) assert len(patches) == 2 assert isinstance(patches, list) assert isinstance(patches[0], Polygon) def test_deblended_labels(self): 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_labels_map == {} assert segm0.deblended_labels_inverse_map == {} 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_labels_map == {5: 2, 6: 2, 7: 3, 8: 3} assert segm.deblended_labels_inverse_map == 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_labels_map == {3: 2, 4: 2, 5: 3, 6: 3} assert_equal(segm2.deblended_labels_inverse_map, 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_labels_map == {12: 2, 13: 2, 14: 3, 15: 3} assert_equal(segm3.deblended_labels_inverse_map, 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_labels_map == {50: 2, 6: 2, 70: 3, 8: 3} assert_equal(segm4.deblended_labels_inverse_map, 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_labels_map == {6: 2, 3: 2, 4: 3, 5: 3} assert_equal(segm5.deblended_labels_inverse_map, deblend_map) class CustomSegm(SegmentationImage): @lazyproperty def value(self): return np.median(self.data) def test_subclass(): """ Test that cached properties are reset in SegmentationImage subclasses. """ data = np.array([[1, 1, 0, 0, 4, 4], [0, 0, 0, 0, 0, 4], [0, 0, 3, 3, 0, 0], [7, 0, 0, 0, 0, 5], [7, 7, 0, 5, 5, 5], [7, 7, 0, 0, 5, 5]]) segm = CustomSegm(data) _ = segm.slices, segm.labels, segm.value, segm.areas data2 = np.array([[10, 10, 0, 40], [0, 0, 0, 40], [70, 70, 0, 0], [70, 70, 0, 1]]) segm.data = data2 assert len(segm.__dict__) == 3 assert_equal(segm.areas, [1, 2, 2, 4]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/segmentation/tests/test_deblend.py0000644000175100001660000003647014755160622024073 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the deblend module. """ import numpy as np import pytest from astropy.modeling.models import Gaussian2D from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose, assert_equal from photutils.segmentation import (SegmentationImage, deblend_sources, detect_sources) from photutils.segmentation.deblend import (_DeblendParams, _SingleSourceDeblender) from photutils.utils._optional_deps import HAS_SKIMAGE @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') class TestDeblendSources: def setup_class(self): g1 = Gaussian2D(100, 50, 50, 5, 5) g2 = Gaussian2D(100, 35, 50, 5, 5) g3 = Gaussian2D(30, 70, 50, 5, 5) y, x = np.mgrid[0:100, 0:100] self.x = x self.y = y self.data = g1(x, y) + g2(x, y) self.data3 = self.data + g3(x, y) self.threshold = 10 self.npixels = 5 self.segm = detect_sources(self.data, self.threshold, self.npixels) self.segm3 = detect_sources(self.data3, self.threshold, self.npixels) @pytest.mark.parametrize('mode', ['exponential', 'linear', 'sinh']) def test_deblend_sources(self, mode): result = deblend_sources(self.data, self.segm, self.npixels, mode=mode, progress_bar=False) assert result.data.dtype == self.segm.data.dtype if mode == 'linear': # test multiprocessing result2 = deblend_sources(self.data, self.segm, self.npixels, mode=mode, progress_bar=False, nproc=2) assert_equal(result.data, result2.data) assert result2.data.dtype == self.segm.data.dtype assert result.nlabels == 2 assert result.nlabels == len(result.slices) mask1 = (result.data == 1) mask2 = (result.data == 2) assert_allclose(len(result.data[mask1]), len(result.data[mask2])) assert_allclose(np.sum(self.data[mask1]), np.sum(self.data[mask2])) assert_allclose(np.nonzero(self.segm), np.nonzero(result)) assert_equal(result.deblended_labels_inverse_map, {1: [1, 2]}) def test_deblend_multiple_sources(self): g4 = Gaussian2D(100, 50, 15, 5, 5) g5 = Gaussian2D(100, 35, 15, 5, 5) g6 = Gaussian2D(100, 50, 85, 5, 5) g7 = Gaussian2D(100, 35, 85, 5, 5) x = self.x y = self.y data = self.data + g4(x, y) + g5(x, y) + g6(x, y) + g7(x, y) segm = detect_sources(data, self.threshold, self.npixels) result = deblend_sources(data, segm, self.npixels, progress_bar=False) assert result.nlabels == 6 assert result.nlabels == len(result.slices) assert result.areas[0] == result.areas[1] assert result.areas[0] == result.areas[2] assert result.areas[0] == result.areas[3] assert result.areas[0] == result.areas[4] assert result.areas[0] == result.areas[5] def test_deblend_multiple_sources_with_neighbor(self): g1 = Gaussian2D(100, 50, 50, 20, 5, theta=45) g2 = Gaussian2D(100, 35, 50, 5, 5) g3 = Gaussian2D(100, 60, 20, 5, 5) x = self.x y = self.y data = (g1 + g2 + g3)(x, y) segm = detect_sources(data, self.threshold, self.npixels) result = deblend_sources(data, segm, self.npixels, progress_bar=False) assert result.nlabels == 3 def test_deblend_labels(self): g1 = Gaussian2D(100, 50, 50, 20, 5, theta=45) g2 = Gaussian2D(100, 35, 50, 5, 5) g3 = Gaussian2D(100, 60, 20, 5, 5) x = self.x y = self.y data = (g1 + g2 + g3)(x, y) segm = detect_sources(data, self.threshold, self.npixels) result = deblend_sources(data, segm, self.npixels, labels=1, progress_bar=False) assert result.nlabels == 2 @pytest.mark.parametrize(('contrast', 'nlabels'), [(0.001, 6), (0.017, 5), (0.06, 4), (0.1, 3), (0.15, 2), (0.45, 1)]) def test_deblend_contrast(self, contrast, nlabels): y, x = np.mgrid[0:51, 0:151] y0 = 25 data = (Gaussian2D(9.5, 16, y0, 5, 5)(x, y) + Gaussian2D(51, 30, y0, 3, 3)(x, y) + Gaussian2D(30, 42, y0, 5, 5)(x, y) + Gaussian2D(80, 66, y0, 8, 8)(x, y) + Gaussian2D(71, 88, y0, 8, 8)(x, y) + Gaussian2D(18, 119, y0, 7, 7)(x, y)) npixels = 5 segm = detect_sources(data, 1.0, npixels) segm2 = deblend_sources(data, segm, npixels, mode='linear', nlevels=32, contrast=contrast, progress_bar=False) assert segm2.nlabels == nlabels def test_deblend_contrast_levels(self): # regression test for case where contrast = 1.0 y, x = np.mgrid[0:51, 0:151] y0 = 25 data = (Gaussian2D(9.5, 16, y0, 5, 5)(x, y) + Gaussian2D(51, 30, y0, 3, 3)(x, y) + Gaussian2D(30, 42, y0, 5, 5)(x, y) + Gaussian2D(80, 66, y0, 8, 8)(x, y) + Gaussian2D(71, 88, y0, 8, 8)(x, y) + Gaussian2D(18, 119, y0, 7, 7)(x, y)) npixels = 5 segm = detect_sources(data, 1.0, npixels) for contrast in np.arange(1, 11) / 10.0: segm3 = deblend_sources(data, segm, npixels, mode='linear', nlevels=32, contrast=contrast, progress_bar=False) assert segm3.nlabels >= 1 def test_deblend_connectivity(self): data = np.zeros((51, 51)) data[15:36, 15:36] = 10.0 data[14, 36] = 1.0 data[13, 37] = 10 data[14, 14] = 5.0 data[13, 13] = 10.0 data[36, 14] = 10.0 data[37, 13] = 10.0 data[36, 36] = 10.0 data[37, 37] = 10.0 segm = detect_sources(data, 0.1, 1, connectivity=4) assert segm.nlabels == 9 segm2 = deblend_sources(data, segm, 1, mode='linear', connectivity=4, progress_bar=False) assert segm2.nlabels == 9 segm = detect_sources(data, 0.1, 1, connectivity=8) assert segm.nlabels == 1 segm2 = deblend_sources(data, segm, 1, mode='linear', connectivity=8, progress_bar=False) assert segm2.nlabels == 3 match = 'Deblending failed for source' with pytest.raises(ValueError, match=match): deblend_sources(data, segm, 1, mode='linear', connectivity=4, progress_bar=False) def test_deblend_label_assignment(self): """ Regression test to ensure newly-deblended labels are unique. """ y, x = np.mgrid[0:201, 0:101] y0a = 35 y1a = 60 yshift = 100 y0b = y0a + yshift y1b = y1a + yshift data = (Gaussian2D(80, 36, y0a, 8, 8)(x, y) + Gaussian2D(71, 58, y1a, 8, 8)(x, y) + Gaussian2D(30, 36, y1a, 7, 7)(x, y) + Gaussian2D(30, 58, y0a, 7, 7)(x, y) + Gaussian2D(80, 36, y0b, 8, 8)(x, y) + Gaussian2D(71, 58, y1b, 8, 8)(x, y) + Gaussian2D(30, 36, y1b, 7, 7)(x, y) + Gaussian2D(30, 58, y0b, 7, 7)(x, y)) npixels = 5 segm1 = detect_sources(data, 5.0, npixels) segm2 = deblend_sources(data, segm1, npixels, mode='linear', nlevels=32, contrast=0.3, progress_bar=False) assert segm2.nlabels == 4 @pytest.mark.parametrize('mode', ['exponential', 'linear']) def test_deblend_sources_norelabel(self, mode): result = deblend_sources(self.data, self.segm, self.npixels, mode=mode, relabel=False, progress_bar=False) assert result.nlabels == 2 assert_equal(result.labels, [2, 3]) assert_equal(result.deblended_labels_inverse_map, {1: [2, 3]}) assert len(result.slices) <= result.max_label assert len(result.slices) == result.nlabels assert_allclose(np.nonzero(self.segm), np.nonzero(result)) @pytest.mark.parametrize('mode', ['exponential', 'linear']) def test_deblend_three_sources(self, mode): result = deblend_sources(self.data3, self.segm3, self.npixels, mode=mode, progress_bar=False) assert result.nlabels == 3 assert_allclose(np.nonzero(self.segm3), np.nonzero(result)) def test_segment_img(self): segm_wrong = np.ones((2, 2), dtype=int) # ndarray match = 'segment_img must be a SegmentationImage' with pytest.raises(TypeError, match=match): deblend_sources(self.data, segm_wrong, self.npixels, progress_bar=False) segm_wrong = SegmentationImage(segm_wrong) # wrong shape match = 'The data and segmentation image must have the same shape' with pytest.raises(ValueError, match=match): deblend_sources(self.data, segm_wrong, self.npixels, progress_bar=False) def test_invalid_nlevels(self): match = 'nlevels must be >= 1' with pytest.raises(ValueError, match=match): deblend_sources(self.data, self.segm, self.npixels, nlevels=0, progress_bar=False) def test_invalid_contrast(self): match = 'contrast must be >= 0 and <= 1' with pytest.raises(ValueError, match=match): deblend_sources(self.data, self.segm, self.npixels, contrast=-1, progress_bar=False) def test_invalid_mode(self): match = 'mode must be "exponential", "linear", or "sinh"' with pytest.raises(ValueError, match=match): deblend_sources(self.data, self.segm, self.npixels, mode='invalid', progress_bar=False) def test_invalid_connectivity(self): match = 'Invalid connectivity' with pytest.raises(ValueError, match=match): deblend_sources(self.data, self.segm, self.npixels, connectivity='invalid', progress_bar=False) def test_constant_source(self): data = self.data.copy() data[data.nonzero()] = 1.0 result = deblend_sources(data, self.segm, self.npixels, progress_bar=False) assert_allclose(result, self.segm) def test_source_with_negval(self): data = self.data.copy() data -= 20 match = 'The deblending mode of one or more source labels from the' with pytest.warns(AstropyUserWarning, match=match): segm = deblend_sources(data, self.segm, self.npixels, progress_bar=False) assert segm.info['warnings']['nonposmin']['input_labels'] == 1 def test_source_zero_min(self): data = self.data.copy() data -= data[self.segm.data > 0].min() match = 'The deblending mode of one or more source labels from the' with pytest.warns(AstropyUserWarning, match=match): segm = deblend_sources(data, self.segm, self.npixels, progress_bar=False) assert segm.info['warnings']['nonposmin']['input_labels'] == 1 def test_connectivity(self): """ Regression test for #341. """ data = np.zeros((3, 3)) data[0, 0] = 2 data[1, 1] = 2 data[2, 2] = 1 segm = np.zeros(data.shape, dtype=int) segm[data.nonzero()] = 1 segm = SegmentationImage(segm) data = data * 100.0 segm_deblend = deblend_sources(data, segm, npixels=1, connectivity=8, progress_bar=False) assert segm_deblend.nlabels == 1 match = 'Deblending failed for source' with pytest.raises(ValueError, match=match): deblend_sources(data, segm, npixels=1, connectivity=4, progress_bar=False) def test_data_nan(self): """ Test that deblending occurs even if the data within a segment contains one or more NaNs. Regression test for #658. """ data = self.data.copy() data[50, 50] = np.nan segm2 = deblend_sources(data, self.segm, 5, progress_bar=False) assert segm2.nlabels == 2 def test_watershed(self): """ Regression test to ensure watershed input mask is bool array. With scikit-image >= 0.13, the mask must be a bool array. In particular, if the mask array contains label 512, the watershed algorithm fails. """ segm = self.segm.copy() segm.reassign_label(1, 512) result = deblend_sources(self.data, segm, self.npixels, progress_bar=False) assert result.nlabels == 2 def test_nondetection(self): """ Test for case where no sources are detected at one of the threshold levels. For this case, a `NoDetectionsWarning` should not be raised when deblending sources. """ data = np.copy(self.data3) data[50, 50] = 1000.0 data[50, 70] = 500.0 self.segm = detect_sources(data, self.threshold, self.npixels) deblend_sources(data, self.segm, self.npixels, progress_bar=False) def test_nonconsecutive_labels(self): segm = self.segm.copy() segm.reassign_label(1, 1000) result = deblend_sources(self.data, segm, self.npixels, progress_bar=False) assert result.nlabels == 2 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 npixels = 5 footprint = np.ones((3, 3)) deblend_params = _DeblendParams(npixels, footprint, 32, 0.001, 'linear') single_debl = _SingleSourceDeblender(data, segm.data, 1, deblend_params) segms = single_debl.multithreshold() assert len(segms) == 32 markers = single_debl.make_markers(return_all=True) assert len(markers) == 19 @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_nmarkers_fallback(): """ If there are too many markers, a warning is raised. """ size = 51 data1 = np.resize([0, 0, 1, 1], size) data1 = np.abs(data1 - np.atleast_2d(data1).T) + 2 for i in range(size): if i % 2 == 0: data1[i, :] = 1 data1[:, i] = 1 data = np.zeros((101, 101)) data[25:25 + size, 25:25 + size] = data1 data[50:60, 50:60] = 10.0 segm = detect_sources(data, 0.01, 10) match = 'The deblending mode of one or more source labels from the' with pytest.warns(AstropyUserWarning, match=match): segm2 = deblend_sources(data, segm, 1, mode='exponential') assert segm2.info['warnings']['nmarkers']['input_labels'][0] == 1 mesg = segm2.info['warnings']['nmarkers']['message'] assert mesg.startswith('Deblending mode changed') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/segmentation/tests/test_detect.py0000644000175100001660000002420714755160622023741 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the detect module. """ import astropy.units as u import numpy as np import pytest from astropy.stats import SigmaClip from numpy.testing import assert_allclose, assert_equal from photutils.segmentation.detect import detect_sources, detect_threshold from photutils.segmentation.utils import make_2dgaussian_kernel from photutils.utils.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 nsigma. """ threshold = detect_threshold(DATA, nsigma=0.1) ref = 0.4 * np.ones((3, 3)) assert_allclose(threshold, ref) threshold = detect_threshold(DATA << u.uJy, nsigma=0.1) assert isinstance(threshold, u.Quantity) assert_allclose(threshold.value, ref) def test_nsigma_zero(self): """ Test nsigma=0. """ threshold = detect_threshold(DATA, nsigma=0.0) ref = (1.0 / 3.0) * np.ones((3, 3)) assert_allclose(threshold, ref) def test_background(self): threshold = detect_threshold(DATA, nsigma=1.0, background=1) ref = (5.0 / 3.0) * np.ones((3, 3)) assert_allclose(threshold, ref) def test_background_image(self): background = np.ones((3, 3)) threshold = detect_threshold(DATA, nsigma=1.0, background=background) ref = (5.0 / 3.0) * np.ones((3, 3)) assert_allclose(threshold, ref) threshold = detect_threshold(DATA << u.Jy, nsigma=1.0, background=background << u.Jy) assert isinstance(threshold, u.Quantity) assert_allclose(threshold.value, ref) def test_background_badshape(self): wrong_shape = np.zeros((2, 2)) match = 'input background is 2D, then it must have the same shape' with pytest.raises(ValueError, match=match): detect_threshold(DATA, nsigma=2.0, background=wrong_shape) def test_error(self): threshold = detect_threshold(DATA, nsigma=1.0, error=1) ref = (4.0 / 3.0) * np.ones((3, 3)) assert_allclose(threshold, ref) def test_error_image(self): error = np.ones((3, 3)) threshold = detect_threshold(DATA, nsigma=1.0, error=error) ref = (4.0 / 3.0) * np.ones((3, 3)) assert_allclose(threshold, ref) threshold = detect_threshold(DATA << u.Jy, nsigma=1.0, error=error << u.Jy) assert isinstance(threshold, u.Quantity) assert_allclose(threshold.value, ref) def test_error_badshape(self): wrong_shape = np.zeros((2, 2)) match = 'If input error is 2D, then it must have the same shape' with pytest.raises(ValueError, match=match): detect_threshold(DATA, nsigma=2.0, error=wrong_shape) def test_background_error(self): threshold = detect_threshold(DATA, nsigma=2.0, background=10.0, error=1.0) ref = 12.0 * np.ones((3, 3)) assert_allclose(threshold, ref) threshold = detect_threshold(DATA << u.Jy, nsigma=2.0, background=10.0 * u.Jy, error=1.0 * u.Jy) assert isinstance(threshold, u.Quantity) assert_allclose(threshold.value, ref) match = 'must all have the same units' with pytest.raises(ValueError, match=match): detect_threshold(DATA << u.Jy, nsigma=2.0, background=10.0, error=1.0 * u.Jy) with pytest.raises(ValueError, match=match): detect_threshold(DATA << u.Jy, nsigma=2.0, background=10.0 * u.m, error=1.0 * u.Jy) def test_background_error_images(self): background = np.ones((3, 3)) * 10.0 error = np.ones((3, 3)) threshold = detect_threshold(DATA, nsigma=2.0, background=background, error=error) ref = 12.0 * np.ones((3, 3)) assert_allclose(threshold, ref) def test_image_mask(self): """ Test detection with image_mask. Set sigma=10 and iters=1 to prevent sigma clipping after applying the mask. """ mask = REF1.astype(bool) sigma_clip = SigmaClip(sigma=10, maxiters=1) threshold = detect_threshold(DATA, nsigma=1.0, error=0, mask=mask, sigma_clip=sigma_clip) ref = (1.0 / 8.0) * np.ones((3, 3)) assert_equal(threshold, ref) def test_invalid_sigma_clip(self): match = 'sigma_clip must be a SigmaClip object' with pytest.raises(TypeError, match=match): detect_threshold(DATA, 1.0, sigma_clip=10) class TestDetectSources: def setup_class(self): self.data = np.array([[0, 1, 0], [0, 2, 0], [0, 0, 0]]).astype(float) self.refdata = np.array([[0, 1, 0], [0, 1, 0], [0, 0, 0]]) kernel = make_2dgaussian_kernel(2.0, size=3) self.kernel = kernel def test_detection(self): """ Test basic detection. """ segm = detect_sources(self.data, threshold=0.9, npixels=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, npixels=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, npixels=2) with pytest.raises(ValueError, match=match): detect_sources(self.data, threshold=0.9 * u.Jy, npixels=2) with pytest.raises(ValueError, match=match): detect_sources(self.data << u.uJy, threshold=0.9 * u.m, npixels=2) def test_small_sources(self): """ Test detection where sources are smaller than npixels size. """ match = 'No sources were found' with pytest.warns(NoDetectionsWarning, match=match): detect_sources(self.data, threshold=0.9, npixels=5) def test_npixels(self): """ Test removal of sources whose size is less than npixels. Regression tests for #663. """ data = np.zeros((8, 8)) data[0:4, 0] = 1 data[0, 0:4] = 1 data[3, 3:] = 2 data[3:, 3] = 2 segm = detect_sources(data, 0, npixels=4) assert segm.nlabels == 2 assert segm.data.dtype == np.int32 # removal of labels with size less than npixels # dtype should still be np.int32 segm = detect_sources(data, 0, npixels=8) assert segm.nlabels == 1 assert segm.data.dtype == np.int32 segm = detect_sources(data, 0, npixels=9) assert segm.nlabels == 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 npixels = np.arange(9, 14) for npixels in np.arange(9, 14): segm = detect_sources(data, 0, npixels=npixels) assert segm.nlabels == 1 assert segm.areas[0] == 13 match = 'No sources were found' with pytest.warns(NoDetectionsWarning, match=match): detect_sources(data, 0, npixels=14) def test_zerothresh(self): """ Test detection with zero threshold. """ segm = detect_sources(self.data, threshold=0.0, npixels=2) assert_equal(segm.data, self.refdata) def test_zerodet(self): """ Test detection with large threshold giving no detections. """ match = 'No sources were found' with pytest.warns(NoDetectionsWarning, match=match): detect_sources(self.data, threshold=7, npixels=2) def test_8connectivity(self): """ Test detection with connectivity=8. """ data = np.eye(3) segm = detect_sources(data, threshold=0.9, npixels=1, connectivity=8) assert_equal(segm.data, data) def test_4connectivity(self): """ Test detection with connectivity=4. """ data = np.eye(3) ref = np.diag([1, 2, 3]) segm = detect_sources(data, threshold=0.9, npixels=1, connectivity=4) assert_equal(segm.data, ref) def test_npixels_nonint(self): """ Test if error raises if npixel is non-integer. """ match = 'npixels must be a positive integer' with pytest.raises(ValueError, match=match): detect_sources(self.data, threshold=1, npixels=0.1) def test_npixels_negative(self): """ Test if error raises if npixel is negative. """ match = 'npixels must be a positive integer' with pytest.raises(ValueError, match=match): detect_sources(self.data, threshold=1, npixels=-1) def test_connectivity_invalid(self): """ Test if error raises if connectivity is invalid. """ match = 'Invalid connectivity=10. Options are 4 or 8' with pytest.raises(ValueError, match=match): detect_sources(self.data, threshold=1, npixels=1, connectivity=10) def test_mask(self): data = np.zeros((11, 11)) data[3:8, 3:8] = 5.0 mask = np.zeros(data.shape, dtype=bool) mask[4:6, 4:6] = True segm1 = detect_sources(data, 1.0, 1.0) segm2 = detect_sources(data, 1.0, 1.0, mask=mask) assert segm2.areas[0] == segm1.areas[0] - mask.sum() # mask with all True 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): 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))) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/segmentation/tests/test_finder.py0000644000175100001660000000604114755160622023734 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the finder module. """ import astropy.units as u import numpy as np import pytest from astropy.convolution import convolve from astropy.modeling.models import Gaussian2D from photutils.datasets import make_100gaussians_image from photutils.segmentation.finder import SourceFinder from photutils.segmentation.utils import make_2dgaussian_kernel from photutils.utils._optional_deps import HAS_SKIMAGE from photutils.utils.exceptions import NoDetectionsWarning class TestSourceFinder: data = make_100gaussians_image() - 5.0 # subtract background kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel, normalize_kernel=True) threshold = 1.5 * 2.0 npixels = 10 @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_deblend(self): finder = SourceFinder(npixels=self.npixels, progress_bar=False) segm1 = finder(self.convolved_data, self.threshold) assert segm1.nlabels == 94 segm2 = finder(self.convolved_data << u.uJy, self.threshold * u.uJy) assert segm2.nlabels == 94 assert np.all(segm1.data == segm2.data) def test_invalid_units(self): finder = SourceFinder(npixels=self.npixels, progress_bar=False) 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): finder = SourceFinder(npixels=self.npixels, deblend=False, progress_bar=False) segm = finder(self.convolved_data, self.threshold) assert segm.nlabels == 87 def test_no_sources(self): finder = SourceFinder(npixels=self.npixels, deblend=True, progress_bar=False) match = 'No sources were found' with pytest.warns(NoDetectionsWarning, match=match): segm = finder(self.convolved_data, 1000) assert segm is None @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') def test_npixels_tuple(self): g1 = Gaussian2D(10, 35, 45, 5, 5) g2 = Gaussian2D(10, 50, 50, 5, 5) g3 = Gaussian2D(10, 66, 55, 5, 5) yy, xx = np.mgrid[0:101, 0:101] data = g1(xx, yy) + g2(xx, yy) + g3(xx, yy) sf1 = SourceFinder(npixels=200) segm1 = sf1(data, threshold=0.1) assert segm1.nlabels == 1 sf2 = SourceFinder(npixels=(200, 5)) segm2 = sf2(data, threshold=0.1) assert segm2.nlabels == 3 def test_repr(self): finder = SourceFinder(npixels=self.npixels, deblend=False, progress_bar=False) cls_repr = repr(finder) assert cls_repr.startswith(finder.__class__.__name__) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/segmentation/tests/test_utils.py0000644000175100001660000000756114755160622023635 0ustar00runnerdocker# 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(): 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(): 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(): footprint = _make_binary_structure(1, 4) assert_allclose(footprint, np.array([1, 1, 1])) footprint = _make_binary_structure(3, 4) assert_equal(footprint[0, 0], np.array([False, False, False])) expected = np.array([[[0, 0, 0], [0, 1, 0], [0, 0, 0]], [[0, 1, 0], [1, 1, 1], [0, 1, 0]], [[0, 0, 0], [0, 1, 0], [0, 0, 0]]]) assert_equal(footprint.astype(int), expected) def test_mask_to_mirrored_value(): center = (2.0, 2.0) data = np.arange(25).reshape(5, 5) mask = np.zeros(data.shape, dtype=bool) mask[0, 0] = True mask[1, 1] = True data_ref = data.copy() data_ref[0, 0] = data[4, 4] data_ref[1, 1] = data[3, 3] mirror_data = _mask_to_mirrored_value(data, mask, center) assert_allclose(mirror_data, data_ref, rtol=0, atol=1.0e-6) def test_mask_to_mirrored_value_range(): """ Test mask_to_mirrored_value when mirrored pixels are outside of the image. """ center = (3.0, 3.0) data = np.arange(25).reshape(5, 5) mask = np.zeros(data.shape, dtype=bool) mask[0, 0] = True mask[1, 1] = True mask[2, 2] = True data_ref = data.copy() data_ref[0, 0] = 0.0 data_ref[1, 1] = 0.0 data_ref[2, 2] = data[4, 4] mirror_data = _mask_to_mirrored_value(data, mask, center) assert_allclose(mirror_data, data_ref, rtol=0, atol=1.0e-6) def test_mask_to_mirrored_value_masked(): """ Test mask_to_mirrored_value when mirrored pixels are also in the replace_mask. """ center = (2.0, 2.0) data = np.arange(25).reshape(5, 5) mask = np.zeros(data.shape, dtype=bool) mask[0, 0] = True mask[1, 1] = True mask[3, 3] = True mask[4, 4] = True data_ref = data.copy() data_ref[0, 0] = 0.0 data_ref[1, 1] = 0.0 data_ref[3, 3] = 0.0 data_ref[4, 4] = 0.0 mirror_data = _mask_to_mirrored_value(data, mask, center) mirror_data = _mask_to_mirrored_value(data, mask, center) assert_allclose(mirror_data, data_ref, rtol=0, atol=1.0e-6) def test_mask_to_mirrored_value_mask_keyword(): """ Test mask_to_mirrored_value when mirrored pixels are masked (via the mask keyword). """ center = (2.0, 2.0) data = np.arange(25.0).reshape(5, 5) replace_mask = np.zeros(data.shape, dtype=bool) mask = np.zeros(data.shape, dtype=bool) replace_mask[0, 2] = True data[4, 2] = np.nan mask[4, 2] = True result = _mask_to_mirrored_value(data, replace_mask, center, mask=mask) assert result[0, 2] == 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/segmentation/utils.py0000644000175100001660000001356014755160622021430 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides utility functions for image segmentation. """ import numpy as np from astropy.convolution import Gaussian2DKernel from astropy.stats import gaussian_fwhm_to_sigma from scipy.ndimage import generate_binary_structure from photutils.utils._parameters import as_pair __all__ = ['make_2dgaussian_kernel'] def make_2dgaussian_kernel(fwhm, size, mode='oversample', oversampling=10): """ Make a normalized 2D circular Gaussian kernel. The kernel must have odd sizes in both X and Y, be centered in the central pixel, and normalized to sum to 1. Parameters ---------- fwhm : float The full-width at half-maximum (FWHM) of the 2D circular Gaussian kernel. size : int or (2,) int array_like The size of the kernel along each axis. If ``size`` is a scalar then a square size of ``size`` will be used. If ``size`` has two elements, they must be in ``(ny, nx)`` (i.e., array shape) order. ``size`` must have odd values for both axes. mode : {'oversample', 'center', 'linear_interp', 'integrate'}, optional The mode to use for discretizing the 2D Gaussian model: * 'oversample' (default): Discretize model by taking the average on an oversampled grid. * 'center': Discretize model by taking the value at the center of the bin. * 'linear_interp': Discretize model by performing a bilinear interpolation between the values at the corners of the bin. * 'integrate': Discretize model by integrating the model over the bin. oversampling : int, optional The oversampling factor used when ``mode='oversample'``. Returns ------- kernel : `astropy.convolution.Kernel2D` The output smoothing kernel, normalized such that it sums to 1. """ ysize, xsize = as_pair('size', size, lower_bound=(0, 1), check_odd=True) kernel = Gaussian2DKernel(fwhm * gaussian_fwhm_to_sigma, x_size=xsize, y_size=ysize, mode=mode, factor=oversampling) kernel.normalize(mode='integral') # ensure kernel sums to 1 return kernel def _make_binary_structure(ndim, connectivity): """ Make a binary structure element. Parameters ---------- ndim : int The number of array dimensions. connectivity : {4, 8} For the case of ``ndim=2``, the type of pixel connectivity used in determining how pixels are grouped into a detected source. The options are 4 or 8 (default). 4-connected pixels touch along their edges. 8-connected pixels touch along their edges or corners. For reference, SourceExtractor uses 8-connected pixels. Returns ------- array : `~numpy.ndarray` The binary structure element. If ``ndim <= 2`` an array of int is returned, otherwise an array of bool is returned. """ if ndim == 1: footprint = np.array((1, 1, 1)) elif ndim == 2: if connectivity == 4: footprint = np.array(((0, 1, 0), (1, 1, 1), (0, 1, 0))) elif connectivity == 8: footprint = np.ones((3, 3), dtype=int) else: raise ValueError(f'Invalid connectivity={connectivity}. ' 'Options are 4 or 8.') else: footprint = generate_binary_structure(ndim, 1) return footprint def _mask_to_mirrored_value(data, replace_mask, xycenter, mask=None): """ Replace masked pixels with the value of the pixel mirrored across a given center position. If the mirror pixel is unavailable (i.e., it is outside of the image or masked), then the masked pixel value is set to zero. Parameters ---------- data : 2D `~numpy.ndarray` A 2D array. replace_mask : 2D bool `~numpy.ndarray` A boolean mask where `True` values indicate the pixels that should be replaced, if possible, by mirrored pixel values. It must have the same shape as ``data``. xycenter : tuple of two int The (x, y) center coordinates around which masked pixels will be mirrored. mask : 2D bool `~numpy.ndarray` A boolean mask where `True` values indicate ``replace_mask`` *mirrored* pixels that should never be used to fix ``replace_mask`` pixels. In other words, if a pixel in ``replace_mask`` has a mirror pixel in this ``mask``, then the mirrored value is set to zero. Using this keyword prevents potential spreading of known non-finite or bad pixel values. Returns ------- result : 2D `~numpy.ndarray` A 2D array with replaced masked pixels. """ outdata = np.copy(data) ymasked, xmasked = np.nonzero(replace_mask) xmirror = 2 * int(xycenter[0] + 0.5) - xmasked ymirror = 2 * int(xycenter[1] + 0.5) - ymasked # Find mirrored pixels that are outside of the image badmask = ((xmirror < 0) | (ymirror < 0) | (xmirror >= data.shape[1]) | (ymirror >= data.shape[0])) # remove them from the set of replace_mask pixels and set them to # zero if np.any(badmask): outdata[ymasked[badmask], xmasked[badmask]] = 0.0 # remove the badmask pixels from pixels to be replaced goodmask = ~badmask ymasked = ymasked[goodmask] xmasked = xmasked[goodmask] xmirror = xmirror[goodmask] ymirror = ymirror[goodmask] outdata[ymasked, xmasked] = outdata[ymirror, xmirror] # Find mirrored pixels that are masked and replace_mask pixels that are # mirrored to other replace_mask pixels. Set them both to zero. mirror_mask = replace_mask[ymirror, xmirror] if mask is not None: mirror_mask |= mask[ymirror, xmirror] xbad = xmasked[mirror_mask] ybad = ymasked[mirror_mask] outdata[ybad, xbad] = 0.0 return outdata ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1739907483.720927 photutils-2.2.0/photutils/tests/0000755000175100001660000000000014755160634016361 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/tests/__init__.py0000644000175100001660000000000014755160622020455 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/tests/helper.py0000644000175100001660000000036314755160622020211 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides the tools used to run the test suite. """ import pytest from astropy.utils.introspection import minversion PYTEST_LT_80 = not minversion(pytest, '8.0') ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1739907483.723927 photutils-2.2.0/photutils/utils/0000755000175100001660000000000014755160634016357 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/__init__.py0000644000175100001660000000076014755160622020470 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This subpackage provides 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/_convolution.py0000644000175100001660000000515314755160622021450 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for convolving images with a kernel. """ import warnings import numpy as np from astropy.convolution import Kernel2D from astropy.units import Quantity from astropy.utils.exceptions import AstropyUserWarning from scipy.ndimage import convolve as ndi_convolve def _filter_data(data, kernel, mode='constant', fill_value=0.0, check_normalization=False): """ Convolve a 2D image with a 2D kernel. The kernel may either be a 2D `~numpy.ndarray` or a `~astropy.convolution.Kernel2D` object. Parameters ---------- data : array_like The 2D array of the image. kernel : array_like (2D) or `~astropy.convolution.Kernel2D` The 2D kernel used to filter the input ``data``. Filtering the ``data`` will smooth the noise and maximize detectability of objects with a shape similar to the kernel. mode : {'constant', 'reflect', 'nearest', 'mirror', 'wrap'}, optional The ``mode`` determines how the array borders are handled. For the ``'constant'`` mode, values outside the array borders are set to ``fill_value``. The default is ``'constant'``. fill_value : scalar, optional Value to fill data values beyond the array borders if ``mode`` is ``'constant'``. The default is ``0.0``. check_normalization : bool, optional If `True` then a warning will be issued if the kernel is not normalized to 1. Returns ------- result : `~numpy.ndarray` The convolved image. """ 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): warnings.warn('The kernel is not normalized.', AstropyUserWarning) # scipy.ndimage.convolve currently strips units, but be explicit in # case that behavior changes unit = None if isinstance(data, Quantity): unit = data.unit data = data.value # NOTE: if data is int and kernel is float, ndimage.convolve will # return an int image. If the data dtype is int, we make the data # float so that a float image is always returned if np.issubdtype(data.dtype, np.integer): data = data.astype(float) # NOTE: astropy.convolution.convolve fails with zero-sum kernels # (used in findstars) (cf. astropy #1647) result = ndi_convolve(data, kernel_array, mode=mode, cval=fill_value) # reapply the input unit if unit is not None: result <<= unit return result ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/_coords.py0000644000175100001660000000564714755160622020372 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for generating random (x, y) coordinates. """ import warnings from collections import defaultdict import numpy as np from astropy.utils.exceptions import AstropyUserWarning from scipy.spatial import KDTree def apply_separation(xycoords, min_separation): """ Apply a minimum separation to a set of (x, y) coordinates. Coordinates that are closer than the minimum separation are removed. Parameters ---------- xycoords : `~numpy.ndarray` The (x, y) coordinates with shape ``(N, 2)``. min_separation : float The minimum separation in pixels between coordinates. Returns ------- xycoords : `~numpy.ndarray` The (x, y) coordinates with shape ``(N, 2)`` after excluding points closer than the minimum separation. """ tree = KDTree(xycoords) pairs = tree.query_pairs(min_separation, output_type='ndarray') # create a dictionary of nearest neighbors (within min_separation) nn = {} nn = defaultdict(set) for i, j in pairs: nn[i].add(j) nn[j].add(i) keep_idx = [] discard_idx = set() for idx in range(xycoords.shape[0]): if idx not in discard_idx: keep_idx.append(idx) # remove nearest neighbors from the output discard_idx.update(nn.get(idx, set())) return xycoords[keep_idx] def make_random_xycoords(size, x_range, y_range, min_separation=0.0, seed=None): """ Make random (x, y) coordinates. Parameters ---------- size : int The number of coordinates to generate. x_range : tuple The range of x values (min, max). y_range : tuple The range of y values (min, max). min_separation : float, optional The minimum separation in pixels between coordinates. seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Returns ------- xycoords : `~numpy.ndarray` The (x, y) random coordinates with shape ``(size, 2)``. """ if min_separation > 0: # scale the number of random coordinates to account for # some being discarded due to min_separation ncoords = size * 10 rng = np.random.default_rng(seed) xc = rng.uniform(x_range[0], x_range[1], ncoords) yc = rng.uniform(y_range[0], y_range[1], ncoords) xycoords = np.transpose(np.array((xc, yc))) xycoords = apply_separation(xycoords, min_separation) xycoords = xycoords[:size] if len(xycoords) < size: warnings.warn(f'Unable to produce {size!r} coordinates within the ' 'given shape and minimum separation. Only ' f'{len(xycoords)!r} coordinates were generated.', AstropyUserWarning) return xycoords ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/_misc.py0000644000175100001660000000334214755160622020022 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools to return the installed astropy and photutils versions. """ import sys from datetime import UTC, datetime def _get_version_info(): """ Return a dictionary of the installed version numbers for photutils and its dependencies. Returns ------- result : dict A dictionary containing the version numbers for photutils and its dependencies. """ versions = {'Python': sys.version.split()[0]} packages = ('photutils', 'astropy', 'numpy', 'scipy', 'skimage', 'matplotlib', 'gwcs', 'bottleneck') for package in packages: try: pkg = __import__(package) version = pkg.__version__ except ImportError: version = None versions[package] = version return versions def _get_date(utc=False): """ Return a string of the current date/time. Parameters ---------- utc : bool, optional Whether to use the UTC timezone instead of the local timezone. Returns ------- result : str The current date/time. """ now = datetime.now().astimezone() if not utc else 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()} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/_moments.py0000644000175100001660000000323614755160622020553 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provide tools for calculating image moments. """ import numpy as np __all__ = ['_moments', '_moments_central'] def _moments_central(data, center=None, order=1): """ Calculate the central image moments up to the specified order. Parameters ---------- data : 2D array_like The input 2D array. center : tuple of two floats or `None`, optional The ``(x, y)`` center position. If `None` it will calculated as the "center of mass" of the input ``data``. order : int, optional The maximum order of the moments to calculate. Returns ------- moments : 2D `~numpy.ndarray` The central image moments. """ data = np.asarray(data).astype(float) if data.ndim != 2: raise ValueError('data must be a 2D array.') if center is None: from photutils.centroids import centroid_com center = centroid_com(data) indices = np.ogrid[tuple(slice(0, i) for i in data.shape)] ypowers = (indices[0] - center[1]) ** np.arange(order + 1) xpowers = np.transpose(indices[1] - center[0]) ** np.arange(order + 1) return np.dot(np.dot(np.transpose(ypowers), data), xpowers) def _moments(data, order=1): """ Calculate the raw image moments up to the specified order. Parameters ---------- data : 2D array_like The input 2D array. order : int, optional The maximum order of the moments to calculate. Returns ------- moments : 2D `~numpy.ndarray` The raw image moments. """ return _moments_central(data, center=(0, 0), order=order) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/_optional_deps.py0000644000175100001660000000264514755160622021734 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for optional dependencies. """ import importlib # Check for optional dependencies using lazy import from `PEP 562 # `_. # This list is a duplicate of the dependencies in pyproject.toml "all". # Note that in some cases the package names are different from the # pip-install name (e.g.k scikit-image -> skimage). optional_deps = ['matplotlib', 'regions', 'skimage', 'gwcs', 'bottleneck', 'tqdm', 'rasterio', 'shapely'] deps = {key.upper(): key for key in optional_deps} __all__ = [f'HAS_{pkg}' for pkg in deps] def __getattr__(name): if name in __all__: try: importlib.import_module(deps[name[4:]]) except ImportError: return False return True raise AttributeError(f'Module {__name__!r} has no attribute {name!r}.') # 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/_parameters.py0000644000175100001660000000530514755160622021233 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides parameter validation tools. """ import numpy as np def as_pair(name, value, lower_bound=None, upper_bound=None, check_odd=False): """ Define a pair of integer values as a 1D array. Parameters ---------- name : str The name of the parameter, which is used in error messages. value : int or int array_like The input value. lower_bound : int or int array_like, optional A tuple defining the allowed lower bound of the value. The first element is the bound and the second element indicates whether the bound is exclusive (0) or inclusive (1). upper_bound : (2,) int tuple, optional A tuple defining the allowed upper bounds of the value along each axis. For each axis, if ``value`` is larger than the bound, it is reset to the bound. ``upper_bound`` is typically set to an image shape. check_odd : bool, optional Whether to raise a `ValueError` if the values are not odd along both axes. Returns ------- result : (2,) `~numpy.ndarray` The pair as a 1D array of two integers. Examples -------- >>> from photutils.utils._parameters import as_pair >>> as_pair('myparam', 4) array([4, 4]) >>> as_pair('myparam', (3, 4)) array([3, 4]) >>> as_pair('myparam', 0, lower_bound=(0, 0)) array([0, 0]) """ value = np.atleast_1d(value) if np.any(~np.isfinite(value)): raise ValueError(f'{name} must be a finite value') if len(value) == 1: value = np.array((value[0], value[0])) if len(value) != 2: raise ValueError(f'{name} must have 1 or 2 elements') if value.ndim != 1: raise ValueError(f'{name} must be 1D') if value.dtype.kind != 'i': raise ValueError(f'{name} must have integer values') if check_odd and np.all(value % 2) != 1: raise ValueError(f'{name} must have an odd value for both axes') if lower_bound is not None: if len(lower_bound) != 2: raise ValueError('lower_bound must contain only 2 elements') bound, inclusive = lower_bound if inclusive == 1: oper = '>' mask = value <= bound else: oper = '>=' mask = value < bound if np.any(mask): raise ValueError(f'{name} must be {oper} {bound}') if upper_bound is not None: # if value is larger than upper_bound, set to upper_bound; # upper_bound is typically set to an image shape value = np.array((min(value[0], upper_bound[0]), min(value[1], upper_bound[1]))) return value ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/_progress_bars.py0000644000175100001660000000300714755160622021740 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for progress bars. """ # pylint: disable-next=E0611 from photutils.utils._optional_deps import HAS_TQDM def add_progress_bar(iterable=None, desc=None, total=None, text=False): """ Add a progress bar for an iterable. Parameters ---------- iterable : iterable, optional The iterable for which to add a progress bar. Set to `None` to manually manage the progress bar updates. desc : str, optional The prefix string for the progress bar. total : int, optional The number of expected iterations. If unspecified, len(iterable) is used if possible. text : bool, optional Whether to always use a text-based progress bar. Returns ------- result : tqdm iterable A tqdm progress bar. If in a notebook and ipywidgets is installed, it will return a ipywidgets-based progress bar. Otherwise it will return a text-based progress bar. """ if HAS_TQDM: if text: from tqdm import tqdm else: try: # pragma: no cover # pylint: disable-next=W0611 from ipywidgets import FloatProgress # noqa: F401 from tqdm.auto import tqdm except ImportError: # pragma: no cover from tqdm import tqdm iterable = tqdm(iterable=iterable, desc=desc, total=total) # pragma: no cover return iterable ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/_quantity_helpers.py0000644000175100001660000000503114755160622022464 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides Quantity helper tools. """ import astropy.units as u import numpy as np def process_quantities(values, names): """ Check and remove units of input values. If any of the input values have units then they all must have units and the units must be the same. The returned values are the input values with units removed and the unit. Parameters ---------- values : list of scalar, `~numpy.ndarray`, or `~astropy.units.Quantity` A list of values. names : list of str A list of names corresponding to the input ``values``. Returns ------- values : list of scalar or `~numpy.ndarray` A list of values, where units have been removed. unit : `~astropy.unit.Unit` The common unit for the input values. `None` will be returned if all the input values do not have units. Raises ------ ValueError If the input values do not all have the same units. """ if len(values) != len(names): raise ValueError('The number of values must match the number of ' 'names.') all_units = {name: getattr(arr, 'unit', None) for arr, name in zip(values, names, strict=True) if arr is not None} unit = set(all_units.values()) if len(unit) > 1: values = list(all_units.keys()) msg = [f'The inputs {values} must all have the same units:'] indent = ' ' * 4 for key, value in all_units.items(): if value is None: msg.append(f'{indent}{key} does not have units') else: msg.append(f'{indent}{key} has units of {value}') msg = '\n'.join(msg) raise ValueError(msg) # extract the unit and remove it from the return values unit = unit.pop() if unit is not None: values = [val.value if val is not None else val for val in values] return values, unit def isscalar(value): """ Check if a value is a scalar. This works for both `~astropy.units.Quantity` and scalars. `numpy.isscalar` always returns False for `~astropy.units.Quantity` objects. Parameters ---------- value : `~astropy.units.Quantity`, scalar, or array_like The value to check. Returns ------- isscalar : bool `True` if the value is a scalar, `False` otherwise. """ if isinstance(value, u.Quantity): return value.isscalar return np.isscalar(value) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/_repr.py0000644000175100001660000000243614755160622020042 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for class __repr__ and __str__ strings. """ def make_repr(instance, params, ellipsis=(), long=False): """ Generate a __repr__ string for a class instance. Parameters ---------- instance : object The class instance. params : list of str List of parameter names to include in the repr. ellipsis : list of str List of parameter names to replace with '...' if not None. long : bool Whether to include the module name in the class name. Returns ------- repr_str : str The generated __repr__ string. """ cls_name = f'{instance.__class__.__name__}' if long: cls_name = f'{instance.__class__.__module__}.{cls_name}' cls_info = [] for param in params: value = getattr(instance, param) if param in ellipsis and value is not None: value = '...' cls_info.append((param, value)) if long: delim = ': ' join_str = '\n' else: delim = '=' join_str = ', ' fmt = [f'{key}{delim}{val!r}' for key, val in cls_info] fmt = f'{join_str}'.join(fmt) if long: return f'<{cls_name}>\n{fmt}' return f'{cls_name}({fmt})' ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/_round.py0000644000175100001660000000125414755160622020216 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools to round numpy arrays. """ import numpy as np def py2intround(a): """ Round the input to the nearest integer. If two integers are equally close, rounding is done away from 0. Parameters ---------- a : float or array_like The input float or array. Returns ------- result : float or array_like The integer-rounded values. """ data = np.atleast_1d(a) value = np.where(data >= 0, np.floor(data + 0.5), np.ceil(data - 0.5)).astype(int) if np.isscalar(a): value = value[0] return value ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/_stats.py0000644000175100001660000001114414755160622020224 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module defines nan-ignoring statistical functions, using bottleneck for performance if available. """ from functools import partial import numpy as np from astropy.units import Quantity from photutils.utils._optional_deps import HAS_BOTTLENECK if HAS_BOTTLENECK: import bottleneck as bn def _move_tuple_axes_last(array, axis): """ Move the specified axes of a NumPy array to the last positions and combine them. Bottleneck can only take integer axis, not tuple, so this function takes all the axes to be operated on and combines them into the last dimension of the array so that we can then use axis=-1. Parameters ---------- array : `~numpy.ndarray` The input array. axis : tuple of int The axes on which to move and combine. Returns ------- array_new : `~numpy.ndarray` Array with the axes being operated on moved into the last dimension. """ other_axes = tuple(i for i in range(array.ndim) if i not in axis) # Move the specified axes to the last positions array_new = np.transpose(array, other_axes + axis) # Reshape the array by combining the moved axes return array_new.reshape(array_new.shape[:len(other_axes)] + (-1,)) def _apply_bottleneck(function, array, axis=None, **kwargs): """ Wrap a bottleneck function to handle tuple axis. This function also takes care to ensure the output is of the expected type, i.e., a quantity, numpy array, or numpy scalar. Parameters ---------- function : callable The bottleneck function to apply. array : `~numpy.ndarray` The array on which to operate. axis : int or tuple of int, optional The axis or axes on which to operate. **kwargs : dict, optional Additional keyword arguments to pass to the bottleneck function. Returns ------- result : `~numpy.ndarray` or float The result of the bottleneck function when called with the ``array``, ``axis``, and ``kwargs``. """ if isinstance(axis, tuple): array = _move_tuple_axes_last(array, axis=axis) axis = -1 result = function(array, axis=axis, **kwargs) if isinstance(array, Quantity): if function == bn.nanvar: result <<= array.unit ** 2 else: result = array.__array_wrap__(result) return result if isinstance(result, float): # For compatibility with numpy, always return a numpy scalar. return np.float64(result) return result bn_funcs = { 'nansum': partial(_apply_bottleneck, bn.nansum), 'nanmin': partial(_apply_bottleneck, bn.nanmin), 'nanmax': partial(_apply_bottleneck, bn.nanmax), 'nanmean': partial(_apply_bottleneck, bn.nanmean), 'nanmedian': partial(_apply_bottleneck, bn.nanmedian), 'nanstd': partial(_apply_bottleneck, bn.nanstd), 'nanvar': partial(_apply_bottleneck, bn.nanvar), } np_funcs = { 'nansum': np.nansum, 'nanmin': np.nanmin, 'nanmax': np.nanmax, 'nanmean': np.nanmean, 'nanmedian': np.nanmedian, 'nanstd': np.nanstd, 'nanvar': np.nanvar, } def _dtype_dispatch(func_name): # dispatch to bottleneck or numpy depending on the input array dtype # this is done to workaround known accuracy bugs in bottleneck # affecting float32 calculations # see https://github.com/pydata/bottleneck/issues/379 # see https://github.com/pydata/bottleneck/issues/462 # see https://github.com/astropy/astropy/issues/17185 # see https://github.com/astropy/astropy/issues/11492 def wrapped(*args, **kwargs): if args[0].dtype.str[1:] == 'f8': return bn_funcs[func_name](*args, **kwargs) return np_funcs[func_name](*args, **kwargs) return wrapped nansum = _dtype_dispatch('nansum') nanmin = _dtype_dispatch('nanmin') nanmax = _dtype_dispatch('nanmax') nanmean = _dtype_dispatch('nanmean') nanmedian = _dtype_dispatch('nanmedian') nanstd = _dtype_dispatch('nanstd') nanvar = _dtype_dispatch('nanvar') else: nansum = np.nansum nanmin = np.nanmin nanmax = np.nanmax nanmean = np.nanmean nanmedian = np.nanmedian nanstd = np.nanstd nanvar = np.nanvar ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/_wcs_helpers.py0000644000175100001660000000402614755160622021405 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides WCS helper tools. """ import astropy.units as u import numpy as np def _pixel_scale_angle_at_skycoord(skycoord, wcs, offset=1 * u.arcsec): """ Calculate the pixel coordinate scale and WCS rotation angle at the position of a SkyCoord coordinate. Parameters ---------- skycoord : `~astropy.coordinates.SkyCoord` The SkyCoord coordinate. wcs : WCS object A world coordinate system (WCS) transformation that supports the `astropy shared interface for WCS `_ (e.g., `astropy.wcs.WCS`, `gwcs.wcs.WCS`). offset : `~astropy.units.Quantity` A small angular offset to use to compute the pixel scale and position angle. Returns ------- xypos : tuple of float The (x, y) pixel coordinate. scale : `~astropy.units.Quantity` The pixel scale in arcsec/pixel. angle : `~astropy.units.Quantity` The angle (in degrees) measured counterclockwise from the positive x axis to the "North" axis of the celestial coordinate system. Notes ----- If distortions are present in the image, the x and y pixel scales likely differ. This function computes a single pixel scale along the North/South axis. """ # Convert to pixel coordinates xpos, ypos = wcs.world_to_pixel(skycoord) # We take a point directly North (i.e., latitude offset) the # input sky coordinate and convert it to pixel coordinates, # then we use the pixel deltas between the input and offset sky # coordinate to calculate the pixel scale and angle. skycoord_offset = skycoord.directional_offset_by(0.0, offset) x_offset, y_offset = wcs.world_to_pixel(skycoord_offset) dx = x_offset - xpos dy = y_offset - ypos scale = offset.to(u.arcsec) / (np.hypot(dx, dy) * u.pixel) angle = (np.arctan2(dy, dx) * u.radian).to(u.deg) return (xpos, ypos), scale, angle ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/colormaps.py0000644000175100001660000000242514755160622020730 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for generating matplotlib colormaps. """ import numpy as np __all__ = ['make_random_cmap'] def make_random_cmap(ncolors=256, seed=None): """ Make a matplotlib colormap consisting of (random) muted colors. A random colormap is very useful for plotting segmentation images. Parameters ---------- ncolors : int, optional The number of colors in the colormap. The default is 256. seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Separate function calls with the same ``seed`` will generate the same colormap. Returns ------- cmap : `matplotlib.colors.ListedColormap` The matplotlib colormap with random colors in RGBA format. """ from matplotlib import colors rng = np.random.default_rng(seed) hue = rng.uniform(low=0.0, high=1.0, size=ncolors) sat = rng.uniform(low=0.2, high=0.7, size=ncolors) val = rng.uniform(low=0.5, high=1.0, size=ncolors) hsv = np.dstack((hue, sat, val)) rgb = np.squeeze(colors.hsv_to_rgb(hsv)) return colors.ListedColormap(colors.to_rgba_array(rgb)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/cutouts.py0000644000175100001660000001716514755160622020446 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for generating 2D image cutouts. """ import numpy as np from astropy.nddata import NoOverlapError, extract_array, overlap_slices from astropy.utils import lazyproperty from photutils.aperture import BoundingBox __all__ = ['CutoutImage'] def _overlap_slices(large_array_shape, small_array_shape, position, mode='partial'): slc_lg, slc_sm = overlap_slices(large_array_shape, small_array_shape, position, mode=mode) # TEMP: remove when required Astropy >= 6.1.1 # fix for https://github.com/astropy/astropy/pull/16544 for i in (0, 1): if slc_lg[i].stop - slc_lg[i].start == 0: raise NoOverlapError('Arrays do not overlap.') return slc_lg, slc_sm class CutoutImage: """ Create a cutout object from a 2D array. The returned object will contain a 2D cutout array. If ``copy=False`` (default), the cutout array is a view into the original ``data`` array, otherwise the cutout array will contain a copy of the original data. Parameters ---------- data : `~numpy.ndarray` The 2D data array from which to extract the cutout array. position : 2 tuple The ``(y, x)`` position of the center of the cutout array with respect to the ``data`` array. shape : 2 tuple of int The shape of the cutout array along each axis in ``(ny, nx)`` order. mode : {'trim', 'partial', 'strict'}, optional The mode used for creating the cutout data array. For the ``'partial'`` and ``'trim'`` modes, a partial overlap of the cutout array and the input ``data`` array is sufficient. For the ``'strict'`` mode, the cutout array has to be fully contained within the ``data`` array, otherwise an `~astropy.nddata.utils.PartialOverlapError` is raised. In all modes, non-overlapping arrays will raise a `~astropy.nddata.utils.NoOverlapError`. In ``'partial'`` mode, positions in the cutout array that do not overlap with the ``data`` array will be filled with ``fill_value``. In ``'trim'`` mode only the overlapping elements are returned, thus the resulting cutout array may be smaller than the requested ``shape``. fill_value : float or int, optional If ``mode='partial'``, the value to fill pixels in the cutout array that do not overlap with the input ``data``. ``fill_value`` must have the same ``dtype`` as the input ``data`` array. copy : bool, optional If `False` (default), then the cutout data will be a view into the original ``data`` array. If `True`, then the cutout data will hold a copy of the original ``data`` array. Examples -------- >>> import numpy as np >>> from photutils.utils import CutoutImage >>> data = np.arange(20.0).reshape(5, 4) >>> cutout = CutoutImage(data, (2, 2), (3, 3)) >>> print(cutout.data) # doctest: +FLOAT_CMP [[ 5. 6. 7.] [ 9. 10. 11.] [13. 14. 15.]] >>> cutout2 = CutoutImage(data, (0, 0), (3, 3), mode='partial') >>> print(cutout2.data) # doctest: +FLOAT_CMP [[nan nan nan] [nan 0. 1.] [nan 4. 5.]] """ def __init__(self, data, position, shape, mode='trim', fill_value=np.nan, copy=False): self.position = position self.input_shape = 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): cutout_data = extract_array(data, self.input_shape, self.position, mode=self.mode, fill_value=self.fill_value, return_position=False) if self.copy: cutout_data = np.copy(cutout_data) return cutout_data def __array__(self, dtype=None): """ Array representation of the cutout data array (e.g., for matplotlib). 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. """ return np.asarray(self.data, dtype=dtype) def __str__(self): cls_name = f'<{self.__class__.__module__}.{self.__class__.__name__}>' props = f'Shape: {self.data.shape}' return f'{cls_name}\n' + props def __repr__(self): return self.__str__() @lazyproperty def slices_original(self): """ A tuple of slice objects in axis order for the minimal bounding box of the cutout with respect to the original array. For ``mode='partial'``, the slices are for the valid (non-filled) cutout values. """ return self._overlap_slices[0] @lazyproperty def slices_cutout(self): """ A tuple of slice objects in axis order for the minimal bounding box of the cutout with respect to the cutout array. For ``mode='partial'``, the slices are for the valid (non-filled) cutout values. """ return self._overlap_slices[1] def _calc_bbox(self, slices): """ Calculate the `~photutils.aperture.BoundingBox` of the rectangular bounding box from the input slices. Parameters ---------- slices : tuple of slice The slices for the bounding box. """ return BoundingBox(ixmin=slices[1].start, ixmax=slices[1].stop, iymin=slices[0].start, iymax=slices[0].stop) @lazyproperty def bbox_original(self): """ The `~photutils.aperture.BoundingBox` of the minimal rectangular region of the cutout array with respect to the original array. For ``mode='partial'``, the bounding box indices are for the valid (non-filled) cutout values. """ return self._calc_bbox(self.slices_original) @lazyproperty def bbox_cutout(self): """ The `~photutils.aperture.BoundingBox` of the minimal rectangular region of the cutout array with respect to the cutout array. For ``mode='partial'``, the bounding box indices are for the valid (non-filled) cutout values. """ return self._calc_bbox(self.slices_cutout) def _calc_xyorigin(self, slices): """ Calculate the (x, y) origin, taking into account partial overlaps. Parameters ---------- slices : tuple of slice The slices for the bounding box. Returns ------- xyorigin : `~numpy.ndarray` The ``(x, y)`` integer index of the origin pixel of the cutout with respect to the original array. """ xorigin, yorigin = (slices[1].start, slices[0].start) if self.mode == 'partial': yorigin -= self.slices_cutout[0].start xorigin -= self.slices_cutout[1].start return np.array((xorigin, yorigin)) @lazyproperty def xyorigin(self): """ A `~numpy.ndarray` containing the ``(x, y)`` integer index of the origin pixel of the cutout with respect to the original array. The origin index will be negative for cutouts with partial overlaps. """ return self._calc_xyorigin(self.slices_original) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/depths.py0000644000175100001660000004410714755160622020223 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for calculating limiting fluxes. """ import warnings import astropy.units as u import numpy as np from astropy.stats import SigmaClip from astropy.utils.exceptions import AstropyUserWarning from scipy.ndimage import binary_dilation from photutils.utils._coords import apply_separation from photutils.utils._progress_bars import add_progress_bar from photutils.utils._repr import make_repr from photutils.utils.footprints import circular_footprint __all__ = ['ImageDepth'] __doctest_requires__ = {('ImageDepth', 'ImageDepth.*'): ['skimage']} class ImageDepth: r""" Class to calculate the limiting flux and magnitude of an image. Parameters ---------- aper_radius : float The radius (in pixels) of the circular apertures used to compute the image depth. nsigma : float, optional The number of standard deviations at which to compute the image depths. mask_pad : float, optional An additional padding (in pixels) to apply when dilating the input mask. napers : int, optional The number of circular apertures used to compute the image depth. niters : int, optional The number of iterations, each with randomly-generated apertures, for which the image depth will be calculated. overlap : bool, optional Whether to allow the apertures to overlap. overlap_maxiters : int, optional The maximum number of iterations that will be used when attempting to find additional non-overlapping apertures. This keyword has no effect unless ``overlap=False``. While increasing this number may generate more non-overlapping apertures in crowded cases, it will also run slower. seed : int, optional A seed to initialize the `numpy.random.BitGenerator`. If `None`, then fresh, unpredictable entropy will be pulled from the OS. Separate function calls with the same ``seed`` will generate the same results. zeropoint : float, optional The zeropoint used to calculate the magnitude limit from the flux limit: .. math:: m_{\mathrm{lim}} = -2.5 \log_{10} f_{\mathrm{lim}} + \mathrm{zeropoint} sigma_clip : `astropy.stats.SigmaClip` instance, optional A `~astropy.stats.SigmaClip` object that defines the sigma clipping parameters to use when computing the limiting flux. If `None` then no sigma clipping will be performed. progress_bar : bool, optional Whether to display a progress bar. The progress bar requires that the `tqdm `_ optional dependency be installed. Note that the progress bar does not currently work in the Jupyter console due to limitations in ``tqdm``. Attributes ---------- apertures : list of `~photutils.aperture.CircularAperture` A list of circular apertures for each iteration. napers_used : int A list of the number of apertures used for each iteration. fluxes : list of `~numpy.ndarray` A list of arrays containing the flux measurements for each iteration. flux_limits : 1D `~numpy.ndarray` An array of the flux limits for each iteration. mag_limits : 1D `~numpy.ndarray` An array of the magnitude limits for each iteration. Notes ----- The image depth is calculated by placing random circular apertures with the specified radius on blank regions of the image. The number of apertures is specified by the ``napers`` keyword. The blank regions are calculated from an input mask, which should mask both sources in the image and areas without image coverage. The input mask will be dilated with a circular footprint with a radius equal to the input ``aper_radius`` plus ``mask_pad``. The image border is also masked with the same radius. The flux limit is calculated as the standard deviation of the aperture fluxes times the input ``nsigma`` significance level. The aperture flux values can be sigma clipped prior to computing the standard deviation using the ``sigma_clip`` keyword. The flux limit is calculated ``niters`` times, each with a randomly-generated set of circular apertures. The returned flux limit is the average of these flux limits. The magnitude limit is calculated from flux limit using the input ``zeropoint`` keyword as: .. math:: m_{\mathrm{lim}} = -2.5 \log_{10} f_{\mathrm{lim}} + \mathrm{zeropoint} Examples -------- >>> from astropy.convolution import convolve >>> from astropy.visualization import simple_norm >>> from photutils.datasets import make_100gaussians_image >>> from photutils.segmentation import SourceFinder, make_2dgaussian_kernel >>> from photutils.utils import ImageDepth >>> bkg = 5.0 >>> data = make_100gaussians_image() - bkg >>> kernel = make_2dgaussian_kernel(3.0, size=5) >>> convolved_data = convolve(data, kernel) >>> npixels = 10 >>> threshold = 3.2 >>> finder = SourceFinder(npixels=npixels, progress_bar=False) >>> segment_map = finder(convolved_data, threshold) >>> mask = segment_map.make_source_mask() >>> radius = 4 >>> depth = ImageDepth(radius, nsigma=5.0, napers=500, niters=2, ... mask_pad=5, overlap=False, seed=123, ... zeropoint=23.9, progress_bar=False) >>> limits = depth(data, mask) >>> print(np.array(limits)) # doctest: +FLOAT_CMP [68.7403149 19.30697121] .. plot:: :include-source: # plot the random apertures for the first iteration import matplotlib.pyplot as plt from astropy.convolution import convolve from astropy.visualization import simple_norm from photutils.datasets import make_100gaussians_image from photutils.segmentation import SourceFinder, make_2dgaussian_kernel from photutils.utils import ImageDepth bkg = 5.0 data = make_100gaussians_image() - bkg kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) npixels = 10 threshold = 3.2 finder = SourceFinder(npixels=npixels, progress_bar=False) segment_map = finder(convolved_data, threshold) mask = segment_map.make_source_mask() radius = 4 depth = ImageDepth(radius, nsigma=5.0, napers=500, niters=2, overlap=False, seed=123, progress_bar=False) limits = depth(data, mask) fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(9, 3)) norm = simple_norm(data, 'sqrt', percent=99.) ax[0].imshow(data, norm=norm, origin='lower') color = 'orange' depth.apertures[0].plot(ax[0], color=color) ax[0].set_title('Data with blank apertures') ax[1].imshow(mask, origin='lower', interpolation='none') depth.apertures[0].plot(ax[1], color=color) ax[1].set_title('Mask with blank apertures') plt.subplots_adjust(left=0.05, right=0.98, bottom=0.05, top=0.95, wspace=0.15) """ def __init__(self, aper_radius, *, nsigma=5.0, mask_pad=0, napers=1000, niters=10, overlap=True, overlap_maxiters=100, seed=None, zeropoint=0.0, sigma_clip=SigmaClip(sigma=3.0, maxiters=10), progress_bar=True): if aper_radius <= 0: raise ValueError('aper_radius must be > 0') if mask_pad < 0: raise ValueError('mask_pad must be >= 0') self.aper_radius = aper_radius self.nsigma = nsigma self.mask_pad = mask_pad self.napers = napers self.niters = niters self.overlap = overlap self.overlap_maxiters = overlap_maxiters self.seed = seed self.zeropoint = zeropoint self.sigma_clip = sigma_clip self.progress_bar = progress_bar self.rng = np.random.default_rng(self.seed) self.dilate_radius = int(np.ceil(self.aper_radius + self.mask_pad)) self.dilate_footprint = circular_footprint(radius=self.dilate_radius) self.apertures = [] self.napers_used = np.array([]) self.fluxes = [] self.flux_limits = np.array([]) self.mag_limits = np.array([]) def __repr__(self): params = ('aper_radius', 'nsigma', 'mask_pad', 'napers', 'niters', 'overlap', 'overlap_maxiters', 'seed', 'zeropoint', 'sigma_clip', 'progress_bar') return make_repr(self, params) def __call__(self, data, mask): """ Calculate the limiting flux and magnitude of an image. Parameters ---------- data : 2D `~numpy.ndarray` The 2D array, which should be in flux units (not surface brightness units). mask : 2D bool `~numpy.ndarray` A 2D mask array with the same shape as ``data`` where a `True` value indicates the corresponding element of ``data`` is masked. The input array should mask both sources (e.g., from a segmentation image) and regions without image coverage. If `None`, then the entire image will be used. Returns ------- flux_limit, mag_limit : float The flux and magnitude limits. The flux limit is returned in the same units as the input ``data``. The magnitude limit is calculated from the flux limit and the input ``zeropoint``. """ # prevent circular import from photutils.aperture import CircularAperture if mask is None or not np.any(mask): all_xycoords = self._make_all_coords_no_mask(data.shape) else: all_xycoords = self._make_all_coords(mask) if len(all_xycoords) == 0: raise ValueError('There are no unmasked pixel values (including ' 'the masked image borders).') napers = self.napers if not self.overlap: napers2 = 1.5 * self.napers napers = int(min(napers2, 0.1 * len(all_xycoords))) iter_range = range(self.niters) if self.progress_bar: desc = 'Image Depths' iter_range = add_progress_bar(iter_range, desc=desc) # pragma: no cover fluxes = [] flux_limits = [] apertures = [] for _ in iter_range: if self.overlap: xycoords = self._make_coords(all_xycoords, napers) else: # cut the number of coords (only need to input ~10x) xycoords = self._make_coords(all_xycoords, napers * 10) min_separation = self.aper_radius * 2.0 xycoords = apply_separation(xycoords, min_separation) xycoords = xycoords[0:self.napers] apers = CircularAperture(xycoords, r=self.aper_radius) apertures.append(apers) fluxes, _ = apers.do_photometry(data) if self.sigma_clip is not None: fluxes = self.sigma_clip(fluxes, masked=False) # ndarray self.fluxes.append(fluxes) flux_limits.append(self.nsigma * np.std(fluxes)) self.apertures = apertures napers_used = np.array([len(apers) for apers in apertures]) self.napers_used = napers_used if np.any(napers_used < self.napers): warnings.warn(f'Unable to generate {self.napers} non-overlapping ' 'apertures in unmasked regions. The number of ' f'apertures used was less than {self.napers} (see ' 'the "napers_used" ImageDepth object attribute). ' 'To fix this, decrease the number of apertures ' 'and/or aperture size, or increase ' '`overlap_maxiters`. Alternatively, you may set ' 'overlap=True', AstropyUserWarning) if isinstance(flux_limits[0], u.Quantity): units = True self.flux_limits = u.Quantity(flux_limits) else: units = False self.flux_limits = np.array(flux_limits) flux_limit = np.mean(self.flux_limits) if np.any(self.flux_limits == 0): warnings.warn('One or more flux_limit values was zero. This is ' 'likely due to constant image values. Check the ' 'input mask.', AstropyUserWarning) # ignore divide-by-zero RuntimeWarning in log10 with warnings.catch_warnings(): warnings.simplefilter('ignore', RuntimeWarning) flux_limits = self.flux_limits flux_limit_ = flux_limit if units: flux_limits = flux_limits.value flux_limit_ = flux_limit.value self.mag_limits = -2.5 * np.log10(flux_limits) + self.zeropoint mag_limit = -2.5 * np.log10(flux_limit_) + self.zeropoint return flux_limit, mag_limit @staticmethod def _find_slice_axis(data, axis): """ Calculate a slice for the minimal bounding box along an axis for the `True` values of a 2D boolean array. Parameters ---------- data : 2D bool `~numpy.ndarray` The boolean array. axis : int The axis to use (0 or 1). Returns ------- slice : slice object A slice object for the input axis. If the data values along the input axis are all `False`, then the slice object will include the entire axis range. """ xx = np.any(data, axis=axis) if np.all(~xx): idx = 0 if axis else 1 slc = slice(0, data.shape[idx]) else: x0, x1 = np.where(xx)[0][[0, -1]] slc = slice(x0, x1 + 1) return slc def _find_slices(self, data): """ Calculate a tuple slice for the minimal bounding box for the `True` values of a 2D boolean array. Parameters ---------- data : 2D bool `~numpy.ndarray` The boolean array. Returns ------- slices : tuple of slices A tuple of slice objects for each axis of the array. If the data is all `False`, then the slice tuple will include the entire image range. """ xslice = self._find_slice_axis(data, 0) yslice = self._find_slice_axis(data, 1) return yslice, xslice def _mask_border(self, mask): """ Mask pixels around the image border. Parameters ---------- mask : 2D bool `~numpy.ndarray` Boolean mask array. Returns ------- mask : 2D bool `~numpy.ndarray` Boolean mask array. """ mask[:self.dilate_radius, :] = True mask[-self.dilate_radius:, :] = True mask[:, :self.dilate_radius] = True mask[:, -self.dilate_radius:] = True return mask def _dilate_mask(self, mask): """ Dilate the input mask to ensure that apertures do not overlap the mask. The mask is dilated with a circular footprint with a radius equal to the input ``aper_radius`` plus ``mask_pad``. Border pixels are also masked with the same radius. Parameters ---------- mask : 2D bool `~numpy.ndarray` Boolean mask array. Returns ------- mask : 2D bool `~numpy.ndarray` Dilated boolean mask array. """ if np.any(mask): mask = binary_dilation(mask, structure=self.dilate_footprint) return self._mask_border(mask) def _make_all_coords_no_mask(self, shape): """ Return an array of all possible (x, y) coordinates. Border pixels will be excluded. Parameters ---------- shape : 2 tuple of int The array shape. Returns ------- xycoords : 2xN `~numpy.ndarray` The (x, y) coordinates. """ ny, nx = shape # remove the image borders border = self.dilate_radius border2 = 2 * border ny -= border2 nx -= border2 yi, xi = np.mgrid[0:ny, 0:nx] xi = xi.ravel() yi = yi.ravel() # shift back to coordinates to the original image xi += border yi += border return np.column_stack((xi, yi)) def _make_all_coords(self, mask): """ Return an array of all possible unmasked (x, y) coordinates. Border pixels will be excluded. Parameters ---------- mask : 2D bool `~numpy.ndarray` The boolean source mask array. Returns ------- xycoords : 2xN `~numpy.ndarray` The (x, y) coordinates. """ mask_inv = ~self._dilate_mask(mask) mask_slc = self._find_slices(mask_inv) yi, xi = np.nonzero(mask_inv[mask_slc]) # shift back to coordinates to the original (unsliced) image xi += mask_slc[1].start yi += mask_slc[0].start return np.column_stack((xi, yi)) def _make_coords(self, xycoords, napers): """ Randomly choose ``napers`` (without replacement) coordinates from the input ``xycoords``. This function also adds < +/-0.5 pixel random shifts so that the coordinates are not all integers. Parameters ---------- xycoords : 2xN `~numpy.ndarray` The (x, y) coordinates. napers : int The number of aperture to make. Returns ------- xycoords : 2xN `~numpy.ndarray` The (x, y) coordinates. """ if napers > xycoords.shape[0]: raise ValueError('Too many apertures for given unmasked area') idx = self.rng.choice(xycoords.shape[0], napers, replace=False) xycoords = xycoords[idx, :].astype(float) shift = self.rng.uniform(-0.5, 0.5, size=xycoords.shape) xycoords += shift return xycoords ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/errors.py0000644000175100001660000001714214755160622020247 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for calculating total error arrays. """ import astropy.units as u import numpy as np from astropy.utils.misc import isiterable __all__ = ['calc_total_error'] def calc_total_error(data, bkg_error, effective_gain): r""" Calculate a total error array by combining a background-only error array with the Poisson noise of sources. Parameters ---------- data : array_like or `~astropy.units.Quantity` The background-subtracted data array. bkg_error : array_like or `~astropy.units.Quantity` The 1-sigma background-only errors of the input ``data``. ``bkg_error`` should include all sources of "background" error but *exclude* the Poisson error of the sources. ``bkg_error`` must have the same shape as ``data``. If ``data`` and ``bkg_error`` are `~astropy.units.Quantity` objects, then they must have the same units. effective_gain : float, array_like, or `~astropy.units.Quantity` Ratio of counts (e.g., electrons or photons) to the units of ``data`` used to calculate the Poisson error of the sources. If ``effective_gain`` is zero (or contains zero values in an array), then the source Poisson noise component will not be included. In other words, the returned total error value will simply be the ``bkg_error`` value for pixels where ``effective_gain`` is zero. ``effective_gain`` cannot not be negative or contain negative values. Returns ------- total_error : `~numpy.ndarray` or `~astropy.units.Quantity` The total error array. If ``data``, ``bkg_error``, and ``effective_gain`` are all `~astropy.units.Quantity` objects, then ``total_error`` will also be returned as a `~astropy.units.Quantity` object with the same units as the input ``data``. Otherwise, a `~numpy.ndarray` will be returned. Notes ----- To use units, ``data``, ``bkg_error``, and ``effective_gain`` must *all* be `~astropy.units.Quantity` objects. ``data`` and ``bkg_error`` must have the same units. A `ValueError` will be raised if only some of the inputs are `~astropy.units.Quantity` objects or if the ``data`` and ``bkg_error`` units differ. The source Poisson error in countable units (e.g., electrons or photons) is: .. math:: \sigma_{\mathrm{src}} = \sqrt{g_{\mathrm{eff}} I} where :math:`g_{\mathrm{eff}}` is the effective gain (``effective_gain``; image or scalar) and :math:`I` is the ``data`` image. The total error is the combination of the background-only error and the source Poisson error. The total error array :math:`\sigma_{\mathrm{tot}}` in countable units (e.g., electrons or photons) is therefore: .. math:: \sigma_{\mathrm{tot}} = \sqrt{g_{\mathrm{eff}}^2 \sigma_{\mathrm{bkg}}^2 + g_{\mathrm{eff}} I} where :math:`\sigma_{\mathrm{bkg}}` is the background-only error image (``bkg_error``). Converting back to the input ``data`` units gives: .. math:: \sigma_{\mathrm{tot}} = \frac{1}{g_{\mathrm{eff}}} \sqrt{g_{\mathrm{eff}}^2 \sigma_{\mathrm{bkg}}^2 + g_{\mathrm{eff}} I} .. math:: \sigma_{\mathrm{tot}} = \sqrt{\sigma_{\mathrm{bkg}}^2 + \frac{I}{g_{\mathrm{eff}}}} ``effective_gain`` can either be a scalar value or a 2D image with the same shape as the ``data``. A 2D ``effective_gain`` image is useful when the input ``data`` has variable depths across the field (e.g., a mosaic image with non-uniform exposure times). For example, if your input ``data`` are in units of electrons/s then ideally ``effective_gain`` should be an exposure-time map. The Poisson noise component is not included in the output total error for pixels where ``data`` (:math:`I_i)` is negative. For such pixels, :math:`\sigma_{\mathrm{tot}, i} = \sigma_{\mathrm{bkg}, i}`. The Poisson noise component is also not included in the output total error for pixels where the effective gain (:math:`g_{\mathrm{eff}, i}`) is zero. For such pixels, :math:`\sigma_{\mathrm{tot}, i} = \sigma_{\mathrm{bkg}, i}`. To replicate `SourceExtractor`_ errors when it is configured to consider weight maps as gain maps (i.e., 'WEIGHT_GAIN=Y'; which is the default), one should input an ``effective_gain`` calculated as: .. math:: g_{\mathrm{eff}}^{\prime} = g_{\mathrm{eff}} \left( \frac{\mathrm{RMS_{\mathrm{median}}^2}}{\sigma_{\mathrm{bkg}}^2} \right) where :math:`g_{\mathrm{eff}}` is the effective gain, :math:`\sigma_{\mathrm{bkg}}` are the background-only errors, and :math:`\mathrm{RMS_{\mathrm{median}}}` is the median value of the low-resolution background RMS map generated by `SourceExtractor`_. When running `SourceExtractor`_, this value is printed to stdout as "(M+D) RMS: ". If you are using `~photutils.background.Background2D`, the median value of the low-resolution background RMS map is returned via the `~photutils.background.Background2D.background_rms_median` attribute. In that case the total error is: .. math:: \sigma_{\mathrm{tot}} = \sqrt{\sigma_{\mathrm{bkg}}^2 + \left(\frac{I}{g_{\mathrm{eff}}}\right) \left(\frac{\sigma_{\mathrm{bkg}}^2} {\mathrm{RMS_{\mathrm{median}}^2}}\right)} .. _SourceExtractor: https://sextractor.readthedocs.io/en/latest/ """ data = np.asanyarray(data) bkg_error = np.asanyarray(bkg_error) inputs = [data, bkg_error, effective_gain] has_unit = [hasattr(x, 'unit') for x in inputs] use_units = all(has_unit) if any(has_unit) and not use_units: raise ValueError('If any of data, bkg_error, or effective_gain has ' 'units, then they all must all have units.') if use_units: if data.unit != bkg_error.unit: raise ValueError('data and bkg_error must have the same units.') count_units = [u.electron, u.photon] datagain_unit = data.unit * effective_gain.unit if datagain_unit not in count_units: raise u.UnitsError('(data * effective_gain) has units of ' f'{datagain_unit}, but it must have count ' 'units (e.g., u.electron or u.photon).') if not isiterable(effective_gain): effective_gain = np.zeros(data.shape) + effective_gain else: effective_gain = np.asanyarray(effective_gain) if effective_gain.shape != data.shape: raise ValueError('If input effective_gain is 2D, then it must ' 'have the same shape as the input data.') if np.any(effective_gain < 0): raise ValueError('effective_gain must be non-zero everywhere.') if use_units: unit = data.unit data = data.value effective_gain = effective_gain.value # do not include source variance where effective_gain = 0 source_variance = data.copy() mask = effective_gain != 0 source_variance[mask] /= effective_gain[mask] source_variance[~mask] = 0.0 # do not include source variance where data is negative (note that # effective_gain cannot be negative) source_variance = np.maximum(source_variance, 0) if use_units: # source_variance is calculated to have units of (data.unit)**2 # so that it can be added with bkg_error**2 below. The returned # total error will have units of data.unit. source_variance <<= unit**2 return np.sqrt(bkg_error**2 + source_variance) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/exceptions.py0000644000175100001660000000047714755160622021117 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides custom exceptions. """ from astropy.utils.exceptions import AstropyWarning __all__ = ['NoDetectionsWarning'] class NoDetectionsWarning(AstropyWarning): """ A warning class to indicate no sources were detected. """ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/footprints.py0000644000175100001660000000256314755160622021143 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for generating footprints. """ import numpy as np __all__ = ['circular_footprint'] def circular_footprint(radius, dtype=int): """ Create a circular footprint. A pixel is considered to be entirely in or out of the footprint depending on whether its center is in or out of the footprint. The size of the output array is the minimal bounding box for the footprint. Parameters ---------- radius : int The radius of the circular footprint. dtype : data-type, optional The data type of the output `~numpy.ndarray`. Returns ------- footprint : `~numpy.ndarray` A footprint where array elements are 1 within the footprint and 0 otherwise. Examples -------- >>> from photutils.utils import circular_footprint >>> circular_footprint(2) array([[0, 0, 1, 0, 0], [0, 1, 1, 1, 0], [1, 1, 1, 1, 1], [0, 1, 1, 1, 0], [0, 0, 1, 0, 0]]) """ if ~np.isfinite(radius) or radius <= 0 or int(radius) != radius: raise ValueError('radius must be a positive, finite integer greater ' 'than 0') x = np.arange(-radius, radius + 1) xx, yy = np.meshgrid(x, x) return np.array((xx**2 + yy**2) <= radius**2, dtype=dtype) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/interpolation.py0000644000175100001660000002603414755160622021622 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module provides tools for interpolating data. """ import numpy as np from scipy.spatial import cKDTree __all__ = ['ShepardIDWInterpolator'] class ShepardIDWInterpolator: """ Class to perform Inverse Distance Weighted (IDW) interpolation. This interpolator uses a modified version of `Shepard's method `_ (see the Notes section for details). Parameters ---------- coordinates : float, 1D array_like, or NxM array_like Coordinates of the known data points. In general, it is expected that these coordinates are in a form of a NxM-like array where N is the number of points and M is dimension of the coordinate space. When M=1 (1D space), then the ``coordinates`` parameter may be entered as a 1D array or, if only one data point is available, ``coordinates`` can be a scalar number representing the 1D coordinate of the data point. .. note:: If the dimensionality of ``coordinates`` is larger than 2, e.g., if it is of the form N1 x N2 x N3 x ... x Nn x M, then it will be flattened to form an array of size NxM where N = N1 * N2 * ... * Nn. values : float or 1D array_like Values of the data points corresponding to each coordinate provided in ``coordinates``. In general a 1D array is expected. When a single data point is available, then ``values`` can be a scalar number. .. note:: If the dimensionality of ``values`` is larger than 1 then it will be flattened. weights : float or 1D array_like, optional Weights to be associated with each data value. These weights, if provided, will be combined with inverse distance weights (see the Notes section for details). When ``weights`` is `None` (default), then only inverse distance weights will be used. When provided, this input parameter must have the same form as ``values``. leafsize : float, optional The number of points at which the k-d tree algorithm switches over to brute-force. ``leafsize`` must be positive. See `scipy.spatial.cKDTree` for further information. Notes ----- This interpolator uses a slightly modified version of `Shepard's method `_. The essential difference is the introduction of a "regularization" parameter (``reg``) that is used when computing the inverse distance weights: .. math:: w_i = 1 / (d(x, x_i)^{power} + r) By supplying a positive regularization parameter one can avoid singularities at the locations of the data points as well as control the "smoothness" of the interpolation (e.g., make the weights of the neighbors less varied). The "smoothness" of interpolation can also be controlled by the power parameter (``power``). Examples -------- This class can be instantiated using the following syntax:: >>> from photutils.utils import ShepardIDWInterpolator as idw Example of interpolating 1D data:: >>> import numpy as np >>> rng = np.random.default_rng(0) >>> x = rng.random(100) # 100 random values >>> y = np.sin(x) >>> f = idw(x, y) >>> float(f(0.4)) # doctest: +FLOAT_CMP 0.38937843420912366 >>> float(np.sin(0.4)) # doctest: +FLOAT_CMP 0.3894183423086505 >>> xi = rng.random(4) # 4 random values >>> xi # doctest: +FLOAT_CMP array([0.47998792, 0.23237292, 0.80188058, 0.92353016]) >>> f(xi) # doctest: +FLOAT_CMP array([0.46577097, 0.22837422, 0.71856662, 0.80125391]) >>> np.sin(xi) # doctest: +FLOAT_CMP array([0.46176846, 0.23028731, 0.71866503, 0.7977353 ]) NOTE: In the last example, ``xi`` may be a ``Nx1`` array instead of a 1D vector. Example of interpolating 2D data:: >>> rng = np.random.default_rng(0) >>> pos = rng.random((1000, 2)) >>> val = np.sin(pos[:, 0] + pos[:, 1]) >>> f = idw(pos, val) >>> float(f([0.5, 0.6])) # doctest: +FLOAT_CMP 0.8948257014687874 >>> float(np.sin(0.5 + 0.6)) # doctest: +FLOAT_CMP 0.8912073600614354 """ def __init__(self, coordinates, values, weights=None, leafsize=10): coordinates = np.asarray(coordinates) if coordinates.ndim == 0: # scalar coordinate coordinates = np.atleast_2d(coordinates) if coordinates.ndim == 1: coordinates = np.transpose(np.atleast_2d(coordinates)) if coordinates.ndim > 2: coordinates = np.reshape(coordinates, (-1, coordinates.shape[-1])) values = np.asanyarray(values).ravel() ncoords = coordinates.shape[0] if ncoords < 1: raise ValueError('You must enter at least one data point.') if values.shape[0] != ncoords: raise ValueError('The number of values must match the number ' 'of coordinates.') if weights is not None: weights = np.asanyarray(weights).ravel() if weights.shape[0] != ncoords: raise ValueError('The number of weights must match the ' 'number of coordinates.') if np.any(weights < 0.0): raise ValueError('All weight values must be non-negative ' 'numbers.') self.coordinates = coordinates self.ncoords = ncoords self.coords_ndim = coordinates.shape[1] self.values = values self.weights = weights self.kdtree = cKDTree(coordinates, leafsize=leafsize) def __call__(self, positions, n_neighbors=8, eps=0.0, power=1.0, reg=0.0, conf_dist=1.0e-12, dtype=float): """ Evaluate the interpolator at the given positions. Parameters ---------- positions : float, 1D array_like, or NxM array_like Coordinates of the position(s) at which the interpolator should be evaluated. In general, it is expected that these coordinates are in a form of a NxM-like array where N is the number of points and M is dimension of the coordinate space. When M=1 (1D space), then the ``positions`` parameter may be input as a 1D-like array or, if only one data point is available, ``positions`` can be a scalar number representing the 1D coordinate of the data point. .. note:: If the dimensionality of the ``positions`` argument is larger than 2, e.g., if it is of the form N1 x N2 x N3 x ... x Nn x M, then it will be flattened to form an array of size NxM where N = N1 * N2 * ... * Nn. .. warning:: The dimensionality of ``positions`` must match the dimensionality of the ``coordinates`` used during the initialization of the interpolator. n_neighbors : int, optional The maximum number of nearest neighbors to use during the interpolation. eps : float, optional Set to use approximate nearest neighbors; the kth neighbor is guaranteed to be no further than (1 + ``eps``) times the distance to the real *k*-th nearest neighbor. See `scipy.spatial.cKDTree.query` for further information. power : float, optional The power of the inverse distance used for the interpolation weights. See the Notes section for more details. reg : float, optional The regularization parameter. It may be used to control the smoothness of the interpolator. See the Notes section for more details. conf_dist : float, optional The confusion distance below which the interpolator should use the value of the closest data point instead of attempting to interpolate. This is used to avoid singularities at the known data points, especially if ``reg`` is 0.0. dtype : data-type, optional The data type of the output interpolated values. If `None` then the type will be inferred from the type of the ``values`` parameter used during the initialization of the interpolator. """ n_neighbors = int(n_neighbors) if n_neighbors < 1: raise ValueError('n_neighbors must be a positive integer') if conf_dist is not None and conf_dist <= 0.0: conf_dist = None positions = np.asanyarray(positions) if positions.ndim == 0: # assume we have a single 1D coordinate if self.coords_ndim != 1: raise ValueError('The dimensionality of the input position ' 'does not match the dimensionality of the ' 'coordinates used to initialize the ' 'interpolator.') elif positions.ndim == 1: # assume we have a single point if self.coords_ndim not in (1, positions.shape[-1]): raise ValueError('The input position was provided as a 1D ' 'array, but its length does not match the ' 'dimensionality of the coordinates used ' 'to initialize the interpolator.') elif positions.ndim != 2: raise ValueError('The input positions must be an array_like ' 'object of dimensionality no larger than 2.') positions = np.reshape(positions, (-1, self.coords_ndim)) npositions = positions.shape[0] distances, idx = self.kdtree.query(positions, k=n_neighbors, eps=eps) if n_neighbors == 1: return self.values[idx] if dtype is None: dtype = self.values.dtype interp_values = np.zeros(npositions, dtype=dtype) for k in range(npositions): valid_idx = np.isfinite(distances[k]) idk = idx[k][valid_idx] dk = distances[k][valid_idx] if dk.shape[0] == 0: interp_values[k] = np.nan continue if conf_dist is not None: # check if we are close to a known data point confused = (dk <= conf_dist) if np.any(confused): interp_values[k] = self.values[idk[confused][0]] continue w = 1.0 / ((dk**power) + reg) if self.weights is not None: w *= self.weights[idk] wtot = np.sum(w) if wtot > 0.0: interp_values[k] = np.dot(w, self.values[idk]) / wtot else: interp_values[k] = np.nan if len(interp_values) == 1: return interp_values[0] return interp_values ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1739907483.725927 photutils-2.2.0/photutils/utils/tests/0000755000175100001660000000000014755160634017521 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/tests/__init__.py0000644000175100001660000000000014755160622021615 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/tests/test_colormaps.py0000644000175100001660000000112114755160622023121 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the colormaps module. """ import pytest from numpy.testing import assert_allclose from photutils.utils._optional_deps import HAS_MATPLOTLIB from photutils.utils.colormaps import make_random_cmap @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='matplotlib is required') def test_colormap(): ncolors = 100 cmap = make_random_cmap(ncolors, seed=0) assert len(cmap.colors) == ncolors assert cmap.colors.shape == (100, 4) assert_allclose(cmap.colors[0], [0.36951484, 0.42125961, 0.65984082, 1.0]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/tests/test_convolution.py0000644000175100001660000000357614755160622023521 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the convolution module. """ import astropy.units as u from astropy.convolution import Gaussian2DKernel from numpy.testing import assert_allclose from photutils.datasets import make_100gaussians_image from photutils.utils._convolution import _filter_data class TestFilterData: def setup_class(self): self.data = make_100gaussians_image() self.kernel = Gaussian2DKernel(3.0, x_size=3, y_size=3) def test_filter_data(self): filt_data1 = _filter_data(self.data, self.kernel) filt_data2 = _filter_data(self.data, self.kernel.array) assert_allclose(filt_data1, filt_data2) def test_filter_data_units(self): unit = u.electron filt_data = _filter_data(self.data * unit, self.kernel) assert isinstance(filt_data, u.Quantity) assert filt_data.unit == unit def test_filter_data_types(self): """ Test to ensure output is a float array for integer input data. """ filt_data = _filter_data(self.data.astype(int), self.kernel.array.astype(int)) assert filt_data.dtype == float filt_data = _filter_data(self.data.astype(int), self.kernel.array.astype(float)) assert filt_data.dtype == float filt_data = _filter_data(self.data.astype(float), self.kernel.array.astype(int)) assert filt_data.dtype == float filt_data = _filter_data(self.data.astype(float), self.kernel.array.astype(float)) assert filt_data.dtype == float def test_filter_data_kernel_none(self): """ Test for kernel=None. """ kernel = None filt_data = _filter_data(self.data, kernel) assert_allclose(filt_data, self.data) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/tests/test_cutouts.py0000644000175100001660000000637114755160622022644 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the cutouts module. """ import numpy as np import pytest from astropy.nddata.utils import PartialOverlapError from numpy.testing import assert_equal from photutils.aperture import BoundingBox from photutils.datasets import make_100gaussians_image from photutils.utils.cutouts import CutoutImage def test_cutout(): data = make_100gaussians_image() shape = (24, 57) yxpos = (100, 51) cutout = CutoutImage(data, yxpos, shape) assert cutout.position == yxpos assert cutout.input_shape == shape assert cutout.mode == 'trim' assert np.isnan(cutout.fill_value) assert not cutout.copy assert cutout.data.shape == shape assert_equal(cutout.__array__(), cutout.data) assert isinstance(cutout.bbox_original, BoundingBox) assert isinstance(cutout.bbox_cutout, BoundingBox) assert cutout.slices_original == (slice(88, 112, None), slice(23, 80, None)) assert cutout.slices_cutout == (slice(0, 24, None), slice(0, 57, None)) assert_equal(cutout.xyorigin, np.array((23, 88))) cutouts2 = CutoutImage(data, yxpos, np.array(shape)) assert cutouts2.input_shape == shape assert f'Shape: {shape}' in repr(cutout) assert f'Shape: {shape}' in str(cutout) def test_cutout_partial_overlap(): data = make_100gaussians_image() shape = (24, 57) # 'trim' mode cutout = CutoutImage(data, (11, 10), shape) assert cutout.input_shape == shape assert cutout.shape == (23, 39) # 'strict' mode match = 'Arrays overlap only partially' with pytest.raises(PartialOverlapError, match=match): CutoutImage(data, (11, 10), shape, mode='strict') # 'partial' mode cutout = CutoutImage(data, (11, 10), shape, mode='partial') assert cutout.input_shape == shape assert cutout.shape == shape assert (cutout.bbox_original == BoundingBox(ixmin=0, ixmax=39, iymin=0, iymax=23)) assert (cutout.bbox_cutout == BoundingBox(ixmin=18, ixmax=57, iymin=1, iymax=24)) assert cutout.slices_original == (slice(0, 23, None), slice(0, 39, None)) assert cutout.slices_cutout == (slice(1, 24, None), slice(18, 57, None)) assert_equal(cutout.xyorigin, np.array((-18, -1))) # regression test for xyorgin in partial mode when cutout extends # beyond right or top edge data = make_100gaussians_image() shape = (54, 57) cutout = CutoutImage(data, (281, 485), shape, mode='partial') assert_equal(cutout.xyorigin, np.array((457, 254))) assert (cutout.bbox_original == BoundingBox(ixmin=457, ixmax=500, iymin=254, iymax=300)) assert (cutout.bbox_cutout == BoundingBox(ixmin=0, ixmax=43, iymin=0, iymax=46)) assert cutout.slices_original == (slice(254, 300, None), slice(457, 500, None)) assert cutout.slices_cutout == (slice(0, 46, None), slice(0, 43, None)) def test_cutout_copy(): data = make_100gaussians_image() cutout1 = CutoutImage(data, (1, 1), (3, 3), copy=True) cutout1.data[0, 0] = np.nan assert not np.isnan(data[0, 0]) cutout2 = CutoutImage(data, (1, 1), (3, 3), copy=False) cutout2.data[0, 0] = np.nan assert np.isnan(data[0, 0]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/tests/test_depths.py0000644000175100001660000001330214755160622022415 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the depths module. """ import astropy.units as u import numpy as np import pytest from astropy.convolution import convolve from astropy.tests.helper import assert_quantity_allclose from astropy.utils.exceptions import AstropyUserWarning from numpy.testing import assert_allclose from photutils.datasets import make_100gaussians_image from photutils.segmentation import SourceFinder, make_2dgaussian_kernel from photutils.utils._optional_deps import HAS_SKIMAGE from photutils.utils.depths import ImageDepth bool_vals = (True, False) @pytest.mark.skipif(not HAS_SKIMAGE, reason='skimage is required') class TestImageDepth: def setup_class(self): bkg = 5.0 data = make_100gaussians_image() - bkg kernel = make_2dgaussian_kernel(3.0, size=5) convolved_data = convolve(data, kernel) npixels = 10 threshold = 3.2 finder = SourceFinder(npixels=npixels, progress_bar=False) segment_map = finder(convolved_data, threshold) self.data = data self.mask = segment_map.make_source_mask() @pytest.mark.parametrize('units', bool_vals) @pytest.mark.parametrize('overlap', bool_vals) def test_image_depth(self, units, overlap): radius = 4 depth = ImageDepth(radius, nsigma=5.0, napers=100, niters=2, mask_pad=5, overlap=overlap, seed=123, zeropoint=23.9, progress_bar=False) if overlap: exp_limits = (72.65695364143787, 19.246807037943814) else: exp_limits = (71.07332848526178, 19.27073336332396) data = self.data fluxlim = exp_limits[0] if units: data = self.data * u.Jy fluxlim *= u.Jy limits = depth(data, self.mask) assert_allclose(limits[1], exp_limits[1]) if not units: assert_allclose(limits[0], fluxlim) else: assert_quantity_allclose(limits[0], fluxlim) def test_mask_none(self): radius = 4 depth = ImageDepth(radius, nsigma=5.0, napers=100, niters=2, mask_pad=5, overlap=True, seed=123, zeropoint=23.9, progress_bar=False) limits = depth(self.data, mask=None) assert_allclose(limits, (79.348118, 19.151158)) def test_many_apertures(self): radius = 4 depth = ImageDepth(radius, nsigma=5.0, napers=5000, niters=2, mask_pad=5, overlap=True, seed=123, zeropoint=23.9, progress_bar=False) mask = np.zeros(self.data.shape) mask[:, 20:] = True match = 'Too many apertures for given unmasked area' with pytest.raises(ValueError, match=match): depth(self.data, mask) depth = ImageDepth(radius, nsigma=5.0, napers=250, niters=2, mask_pad=5, overlap=False, seed=123, zeropoint=23.9, progress_bar=False) mask = np.zeros(self.data.shape) mask[:, 100:] = True match = r'Unable to generate .* non-overlapping apertures' with pytest.warns(AstropyUserWarning, match=match): depth(self.data, mask) # test for zero non-overlapping apertures before slow loop radius = 5 depth = ImageDepth(radius, nsigma=5.0, napers=100, niters=2, overlap=False, seed=123, zeropoint=23.9, progress_bar=False) mask = np.zeros(self.data.shape) mask[:, 40:] = True match = r'Unable to generate .* non-overlapping apertures' with pytest.warns(AstropyUserWarning, match=match): depth(self.data, mask) def test_zero_data(self): radius = 4 depth = ImageDepth(radius, napers=500, niters=2, overlap=True, seed=123, progress_bar=False) data = np.zeros((300, 400)) mask = None 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): radius = 4 depth = ImageDepth(radius, napers=500, niters=1, mask_pad=5, overlap=True, seed=123, progress_bar=False) data = np.zeros(self.data.shape) mask = np.zeros(data.shape, dtype=bool) mask[:, 10:] = True match = 'There are no unmasked pixel values' with pytest.raises(ValueError, match=match): depth(data, mask) def test_inputs(self): match = 'aper_radius must be > 0' with pytest.raises(ValueError, match=match): ImageDepth(0.0, nsigma=5.0, napers=500, niters=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, nsigma=5.0, napers=500, niters=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, nsigma=5.0, napers=500, niters=2, mask_pad=-7.1, overlap=True, seed=123, zeropoint=23.9, progress_bar=False) def test_repr(self): depth = ImageDepth(aper_radius=4, nsigma=5.0, napers=100, niters=2, overlap=False, seed=123, zeropoint=23.9, progress_bar=False) cls_repr = repr(depth) assert cls_repr.startswith(f'{depth.__class__.__name__}') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/tests/test_errors.py0000644000175100001660000000565114755160622022452 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the errors module. """ import astropy.units as u import numpy as np import pytest from numpy.testing import assert_allclose from photutils.utils.errors import calc_total_error SHAPE = (5, 5) DATAVAL = 2.0 DATA = np.ones(SHAPE) * DATAVAL BKG_ERROR = np.ones(SHAPE) EFFGAIN = np.ones(SHAPE) * DATAVAL BACKGROUND = np.ones(SHAPE) WRONG_SHAPE = np.ones((2, 2)) def test_error_shape(): match = 'operands could not be broadcast together with shapes' with pytest.raises(ValueError, match=match): calc_total_error(DATA, WRONG_SHAPE, EFFGAIN) def test_gain_shape(): 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): match = 'effective_gain must be non-zero everywhere' with pytest.raises(ValueError, match=match): calc_total_error(DATA, BKG_ERROR, effective_gain) def test_gain_scalar(): error_tot = calc_total_error(DATA, BKG_ERROR, 2.0) assert_allclose(error_tot, np.sqrt(2.0) * BKG_ERROR) def test_gain_array(): error_tot = calc_total_error(DATA, BKG_ERROR, EFFGAIN) assert_allclose(error_tot, np.sqrt(2.0) * BKG_ERROR) def test_gain_zero(): error_tot = calc_total_error(DATA, BKG_ERROR, 0.0) assert_allclose(error_tot, BKG_ERROR) effgain = np.copy(EFFGAIN) effgain[0, 0] = 0 effgain[1, 1] = 0 mask = (effgain == 0) error_tot = calc_total_error(DATA, BKG_ERROR, effgain) assert_allclose(error_tot[mask], BKG_ERROR[mask]) assert_allclose(error_tot[~mask], np.sqrt(2)) def test_units(): units = u.electron / u.s error_tot1 = calc_total_error(DATA * units, BKG_ERROR * units, EFFGAIN * u.s) assert error_tot1.unit == units error_tot2 = calc_total_error(DATA, BKG_ERROR, EFFGAIN) assert_allclose(error_tot1.value, error_tot2) def test_error_units(): units = u.electron / u.s 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(): 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(): units = u.electron / u.s match = 'all must all have units' with pytest.raises(ValueError, match=match): calc_total_error(DATA * units, BKG_ERROR, EFFGAIN * u.s) def test_missing_effgain_units(): units = u.electron / u.s match = 'all must all have units' with pytest.raises(ValueError, match=match): calc_total_error(DATA * units, BKG_ERROR * units, EFFGAIN) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/tests/test_footprints.py0000644000175100001660000000225714755160622023344 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the footprints module. """ import numpy as np import pytest from numpy.testing import assert_equal from photutils.utils.footprints import circular_footprint def test_footprints(): footprint = circular_footprint(1) result = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]]) assert_equal(footprint, result) footprint = circular_footprint(2) result = np.array([[0, 0, 1, 0, 0], [0, 1, 1, 1, 0], [1, 1, 1, 1, 1], [0, 1, 1, 1, 0], [0, 0, 1, 0, 0]]) assert_equal(footprint, result) 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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/tests/test_interpolation.py0000644000175100001660000001065614755160622024026 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the interpolation module. """ import numpy as np import pytest from numpy.testing import assert_allclose from photutils.utils import ShepardIDWInterpolator as IDWInterp SHAPE = (5, 5) DATA = np.ones(SHAPE) * 2.0 MASK = np.zeros(DATA.shape, dtype=bool) MASK[2, 2] = True ERROR = np.ones(SHAPE) BACKGROUND = np.ones(SHAPE) WRONG_SHAPE = np.ones((2, 2)) class TestShepardIDWInterpolator: def setup_class(self): self.rng = np.random.default_rng(0) self.x = self.rng.random(100) self.y = np.sin(self.x) self.f = IDWInterp(self.x, self.y) @pytest.mark.parametrize('positions', [0.4, np.arange(2, 5) * 0.1]) def test_idw_1d(self, positions): f = IDWInterp(self.x, self.y) assert_allclose(f(positions), np.sin(positions), atol=1e-2) def test_idw_weights(self): weights = self.y * 0.1 f = 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): 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): val = np.ones((3, 3, 3)) pos = np.indices(val.shape) f = IDWInterp(pos, val) assert_allclose(f([0.5, 0.5, 0.5]), 1.0) def test_no_coordinates(self): match = 'You must enter at least one data point' with pytest.raises(ValueError, match=match): IDWInterp([], 0) def test_values_invalid_shape(self): 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): 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): match = 'All weight values must be non-negative numbers' with pytest.raises(ValueError, match=match): IDWInterp(self.x, self.y, weights=-self.y) def test_n_neighbors_one(self): assert_allclose(self.f(0.5, n_neighbors=1), [0.479334], rtol=3e-7) def test_n_neighbors_negative(self): 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): assert_allclose(self.f(0.5, conf_dist=-1), self.f(0.5, conf_dist=None)) def test_dtype_none(self): result = self.f(0.5, dtype=None) assert result.dtype == float def test_positions_0d_nomatch(self): """ Test when position ndim doesn't match coordinates ndim. """ pos = self.rng.random((10, 2)) val = np.sin(pos[:, 0] + pos[:, 1]) f = 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 when position ndim doesn't match coordinates ndim. """ 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): 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): 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): 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): 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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/tests/test_misc.py0000644000175100001660000000110714755160622022061 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _misc module. """ import pytest from photutils.utils._misc import _get_meta @pytest.mark.parametrize('utc', [False, True]) def test_get_meta(utc): meta = _get_meta(utc) keys = ('date', 'version') for key in keys: assert key in meta versions = meta['version'] assert isinstance(versions, dict) keys = ('Python', 'photutils', 'astropy', 'numpy', 'scipy', 'skimage', 'matplotlib', 'gwcs', 'bottleneck') for key in keys: assert key in versions ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/tests/test_moments.py0000644000175100001660000000233014755160622022607 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the moments module. """ import numpy as np import pytest from numpy.testing import assert_allclose, assert_equal from photutils.utils._moments import _moments, _moments_central def test_moments(): data = np.array([[0, 1], [0, 1]]) moments = _moments(data, order=2) result = np.array([[2, 2, 2], [1, 1, 1], [1, 1, 1]]) assert_equal(moments, result) assert_allclose(moments[0, 1] / moments[0, 0], 1.0) assert_allclose(moments[1, 0] / moments[0, 0], 0.5) def test_moments_central(): data = np.array([[0, 1], [0, 1]]) moments = _moments_central(data, order=2) result = np.array([[2.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.5, 0.0, 0.0]]) assert_allclose(moments, result) def test_moments_central_nonsquare(): data = np.array([[0, 1], [0, 1], [0, 1]]) moments = _moments_central(data, order=2) result = np.array([[3.0, 0.0, 0.0], [0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]) assert_allclose(moments, result) def test_moments_central_invalid_dim(): data = np.arange(27).reshape(3, 3, 3) match = 'data must be a 2D array' with pytest.raises(ValueError, match=match): _moments_central(data, order=3) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/tests/test_parameters.py0000644000175100001660000000173714755160622023302 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the parameters module. """ import numpy as np import pytest from numpy.testing import assert_equal from photutils.utils._parameters import as_pair def test_as_pair(): assert_equal(as_pair('myparam', 4), (4, 4)) assert_equal(as_pair('myparam', (3, 4)), (3, 4)) assert_equal(as_pair('myparam', 0), (0, 0)) match = 'must be > 0' with pytest.raises(ValueError, match=match): as_pair('myparam', 0, lower_bound=(0, 1)) match = 'must be a finite value' with pytest.raises(ValueError, match=match): as_pair('myparam', (1, np.nan)) with pytest.raises(ValueError, match=match): as_pair('myparam', (1, np.inf)) match = 'must have an odd value for both axes' with pytest.raises(ValueError, match=match): as_pair('myparam', (3, 4), check_odd=True) with pytest.raises(ValueError, match=match): as_pair('myparam', 4, check_odd=True) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/tests/test_quantity_helpers.py0000644000175100001660000000360514755160622024533 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _quantity_helpers module. """ import astropy.units as u import numpy as np import pytest from numpy.testing import assert_equal from photutils.utils._quantity_helpers import isscalar, process_quantities @pytest.mark.parametrize('all_units', [False, True]) def test_units(all_units): 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_mixed_units(): 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): _, _ = process_quantities(arrs, names) arrs = (np.ones(3) * u.Jy, np.ones(3)) names = ('a', 'b') with pytest.raises(ValueError, match=match): _, _ = process_quantities(arrs, names) unit = u.Jy arrs = (np.ones(3) * unit, np.ones(3), np.ones(3) * unit) names = ('a', 'b', 'c') with pytest.raises(ValueError, match=match): _, _ = process_quantities(arrs, names) unit = u.Jy arrs = (np.ones(3) * unit, np.ones(3), np.ones(3) * u.km) names = ('a', 'b', 'c') with pytest.raises(ValueError, match=match): _, _ = process_quantities(arrs, names) def test_inputs(): match = 'The number of values must match the number of names' with pytest.raises(ValueError, match=match): _, _ = process_quantities([1, 2, 3], ['a', 'b']) def test_isscalar(): assert isscalar(1) assert isscalar(1.0 * u.m) assert not isscalar([1, 2, 3]) assert not isscalar([1, 2, 3] * u.m) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/tests/test_round.py0000644000175100001660000000117014755160622022255 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _round module. """ import numpy as np from numpy.testing import assert_equal from photutils.utils._round import py2intround def test_round(): a = np.arange(-2, 2, 0.5) ar = py2intround(a) result = np.array([-2, -2, -1, -1, 0, 1, 1, 2]) assert isinstance(ar, np.ndarray) assert ar.shape == a.shape assert_equal(ar, result) def test_round_scalar(): a = 0.5 ar = py2intround(a) assert np.isscalar(ar) assert ar == 1.0 a = -0.5 ar = py2intround(a) assert np.isscalar(ar) assert ar == -1.0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/photutils/utils/tests/test_stats.py0000644000175100001660000000212314755160622022263 0ustar00runnerdocker# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Tests for the _stats module. """ import astropy.units as u import numpy as np import pytest from numpy.testing import 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): 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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907483.0 photutils-2.2.0/photutils/version.py0000644000175100001660000000063314755160633017257 0ustar00runnerdocker# file generated by setuptools_scm # don't change, don't track in version control TYPE_CHECKING = False if TYPE_CHECKING: from typing import Tuple, Union VERSION_TUPLE = Tuple[Union[int, str], ...] else: VERSION_TUPLE = object version: str __version__: str __version_tuple__: VERSION_TUPLE version_tuple: VERSION_TUPLE __version__ = version = '2.2.0' __version_tuple__ = version_tuple = (2, 2, 0) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1739907483.725927 photutils-2.2.0/photutils.egg-info/0000755000175100001660000000000014755160634016711 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907483.0 photutils-2.2.0/photutils.egg-info/PKG-INFO0000644000175100001660000001551114755160633020010 0ustar00runnerdockerMetadata-Version: 2.2 Name: photutils Version: 2.2.0 Summary: An Astropy package for source detection and photometry Author-email: Photutils Developers License: Copyright (c) 2011-2025, Photutils Developers All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Project-URL: Homepage, https://github.com/astropy/photutils Project-URL: Documentation, https://photutils.readthedocs.io/en/stable/ Keywords: astronomy,astrophysics,photometry,aperture,psf,source detection,background,segmentation,centroids,isophote,morphology,radial profiles Classifier: Intended Audience :: Science/Research Classifier: License :: OSI Approved :: BSD License Classifier: Natural Language :: English Classifier: Operating System :: OS Independent Classifier: Programming Language :: Cython Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Topic :: Scientific/Engineering :: Astronomy Requires-Python: >=3.11 Description-Content-Type: text/x-rst License-File: LICENSE.rst Requires-Dist: numpy>=1.24 Requires-Dist: astropy>=5.3 Requires-Dist: scipy>=1.10 Provides-Extra: all Requires-Dist: matplotlib>=3.7; extra == "all" Requires-Dist: regions>=0.9; extra == "all" Requires-Dist: scikit-image>=0.20; extra == "all" Requires-Dist: gwcs>=0.20; extra == "all" Requires-Dist: bottleneck; extra == "all" Requires-Dist: tqdm; extra == "all" Requires-Dist: rasterio; extra == "all" Requires-Dist: shapely; extra == "all" Provides-Extra: test Requires-Dist: pytest-astropy>=0.11; extra == "test" Requires-Dist: pytest-xdist>=2.5.0; extra == "test" Provides-Extra: docs Requires-Dist: photutils[all]; extra == "docs" Requires-Dist: sphinx; extra == "docs" Requires-Dist: sphinx_design; extra == "docs" Requires-Dist: sphinx-astropy[confv2]>=1.9.1; extra == "docs" ========= Photutils ========= |PyPI Version| |Conda Version| |PyPI Downloads| |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 an a `coordinated package `_ of `Astropy`_ and integrates well with other Astropy packages, making it a powerful tool for astronomical image analysis. Please see the `online documentation `_ for `installation instructions `_ and usage information. Citing Photutils ---------------- |Zenodo| If you use Photutils for a project that leads to a publication, whether directly or as a dependency of another package, please include the following acknowledgment:: This research made use of Photutils, an Astropy package for detection and photometry of astronomical sources (Bradley et al. ). where (Bradley et al. ) is a citation to the `Zenodo record `_ of the Photutils version that was used. We also encourage citations in the main text wherever appropriate. Please see the `CITATION `_ file for details and an example BibTeX entry. License ------- Photutils is licensed under a 3-clause BSD license. Please see the `LICENSE `_ file for details. .. |PyPI Version| image:: https://img.shields.io/pypi/v/photutils.svg?logo=pypi&logoColor=white&label=PyPI :target: https://pypi.org/project/photutils/ :alt: PyPI Latest Release .. |Conda Version| image:: https://img.shields.io/conda/vn/conda-forge/photutils :target: https://anaconda.org/conda-forge/photutils :alt: Conda Latest Release .. |PyPI Downloads| image:: https://img.shields.io/pypi/dm/photutils?label=PyPI%20Downloads :target: https://pypistats.org/packages/photutils :alt: PyPI Downloads .. |Astropy| image:: https://img.shields.io/badge/powered%20by-AstroPy-orange.svg?style=flat :target: https://www.astropy.org/ :alt: Powered by Astropy .. |Zenodo| image:: https://zenodo.org/badge/2640766.svg :target: https://zenodo.org/doi/10.5281/zenodo.596036 :alt: Zenodo Latest DOI .. |CI Status| image:: https://github.com/astropy/photutils/workflows/CI%20Tests/badge.svg# :target: https://github.com/astropy/photutils/actions :alt: CI Status .. |Codecov Status| image:: https://img.shields.io/codecov/c/github/astropy/photutils?logo=codecov :target: https://codecov.io/gh/astropy/photutils :alt: Coverage Status .. |Stable RTD Status| image:: https://img.shields.io/readthedocs/photutils/latest.svg?logo=read%20the%20docs&logoColor=white&label=Docs&version=stable :target: https://photutils.readthedocs.io/en/stable/ :alt: Stable Documentation Status .. |Latest RTD Status| image:: https://img.shields.io/readthedocs/photutils/latest.svg?logo=read%20the%20docs&logoColor=white&label=Docs&version=latest :target: https://photutils.readthedocs.io/en/latest/ :alt: Latest Documentation Status .. _Astropy: https://www.astropy.org/ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907483.0 photutils-2.2.0/photutils.egg-info/SOURCES.txt0000644000175100001660000002470514755160633020604 0ustar00runnerdocker.flake8 .gitignore .pre-commit-config.yaml .pycodestyle .readthedocs.yaml CHANGES.rst CITATION.md CODE_OF_CONDUCT.rst CONTRIBUTING.rst LICENSE.rst README.rst codecov.yml pyproject.toml tox.ini ./photutils/geometry/circular_overlap.pyx ./photutils/geometry/core.pyx ./photutils/geometry/elliptical_overlap.pyx ./photutils/geometry/rectangular_overlap.pyx .github/dependabot.yml .github/workflows/ci_cron_daily.yml .github/workflows/ci_cron_weekly.yml .github/workflows/ci_tests.yml .github/workflows/codeql.yml .github/workflows/publish.yml docs/Makefile docs/changelog.rst docs/conf.py docs/index.rst docs/make.bat docs/nitpick-exceptions.txt docs/_static/custom.css docs/_static/photutils_logo.ico docs/_static/photutils_logo_dark_plain_path.svg docs/_static/photutils_logo_light_plain_path.svg docs/development/index.rst docs/development/license.rst docs/development/releasing.rst docs/getting_started/citation.rst docs/getting_started/contributing.rst docs/getting_started/contributors.rst docs/getting_started/importing.rst docs/getting_started/index.rst docs/getting_started/install.rst docs/getting_started/overview.rst docs/getting_started/pixel_conventions.rst docs/reference/aperture_api.rst docs/reference/background_api.rst docs/reference/centroids_api.rst docs/reference/datasets_api.rst docs/reference/detection_api.rst docs/reference/geometry_api.rst docs/reference/index.rst docs/reference/isophote_api.rst docs/reference/morphology_api.rst docs/reference/profiles_api.rst docs/reference/psf_api.rst docs/reference/psf_matching_api.rst docs/reference/segmentation_api.rst docs/reference/utils_api.rst docs/user_guide/aperture.rst docs/user_guide/background.rst docs/user_guide/centroids.rst docs/user_guide/datasets.rst docs/user_guide/detection.rst docs/user_guide/epsf.rst docs/user_guide/geometry.rst docs/user_guide/grouping.rst docs/user_guide/index.rst docs/user_guide/isophote.rst docs/user_guide/isophote_faq.rst docs/user_guide/morphology.rst docs/user_guide/profiles.rst docs/user_guide/psf.rst docs/user_guide/psf_matching.rst docs/user_guide/segmentation.rst docs/user_guide/utils.rst docs/whats_new/1.1.rst docs/whats_new/1.10.rst docs/whats_new/1.11.rst docs/whats_new/1.12.rst docs/whats_new/1.13.rst docs/whats_new/1.2.rst docs/whats_new/1.3.rst docs/whats_new/1.4.rst docs/whats_new/1.5.rst docs/whats_new/1.6.rst docs/whats_new/1.7.rst docs/whats_new/1.8.rst docs/whats_new/1.9.rst docs/whats_new/2.0.rst docs/whats_new/2.1.rst docs/whats_new/2.2.rst docs/whats_new/index.rst photutils/CITATION.rst photutils/__init__.py photutils/_compiler.c photutils/conftest.py photutils/version.py photutils.egg-info/PKG-INFO photutils.egg-info/SOURCES.txt photutils.egg-info/dependency_links.txt photutils.egg-info/not-zip-safe photutils.egg-info/requires.txt photutils.egg-info/top_level.txt photutils/aperture/__init__.py photutils/aperture/attributes.py photutils/aperture/bounding_box.py photutils/aperture/circle.py photutils/aperture/converters.py photutils/aperture/core.py photutils/aperture/ellipse.py photutils/aperture/mask.py photutils/aperture/photometry.py photutils/aperture/rectangle.py photutils/aperture/stats.py photutils/aperture/tests/__init__.py photutils/aperture/tests/test_aperture_common.py photutils/aperture/tests/test_bounding_box.py photutils/aperture/tests/test_circle.py photutils/aperture/tests/test_converters.py photutils/aperture/tests/test_ellipse.py photutils/aperture/tests/test_mask.py photutils/aperture/tests/test_photometry.py photutils/aperture/tests/test_rectangle.py photutils/aperture/tests/test_stats.py photutils/background/__init__.py photutils/background/background_2d.py photutils/background/core.py photutils/background/interpolators.py photutils/background/local_background.py photutils/background/tests/__init__.py photutils/background/tests/test_background_2d.py photutils/background/tests/test_core.py photutils/background/tests/test_interpolators.py photutils/background/tests/test_local_background.py photutils/centroids/__init__.py photutils/centroids/core.py photutils/centroids/gaussian.py photutils/centroids/tests/__init__.py photutils/centroids/tests/test_core.py photutils/centroids/tests/test_gaussian.py photutils/datasets/__init__.py photutils/datasets/examples.py photutils/datasets/images.py photutils/datasets/load.py photutils/datasets/model_params.py photutils/datasets/noise.py photutils/datasets/wcs.py photutils/datasets/data/100gaussians_params.ecsv photutils/datasets/data/4gaussians_params.ecsv photutils/datasets/tests/__init__.py photutils/datasets/tests/test_examples.py photutils/datasets/tests/test_images.py photutils/datasets/tests/test_load.py photutils/datasets/tests/test_model_params.py photutils/datasets/tests/test_noise.py photutils/datasets/tests/test_wcs.py photutils/detection/__init__.py photutils/detection/core.py photutils/detection/daofinder.py photutils/detection/irafstarfinder.py photutils/detection/peakfinder.py photutils/detection/starfinder.py photutils/detection/tests/__init__.py photutils/detection/tests/conftest.py photutils/detection/tests/test_daofinder.py photutils/detection/tests/test_irafstarfinder.py photutils/detection/tests/test_peakfinder.py photutils/detection/tests/test_starfinder.py photutils/extern/__init__.py photutils/extern/biweight.py photutils/geometry/__init__.py photutils/geometry/circular_overlap.pyx photutils/geometry/core.pxd photutils/geometry/core.pyx photutils/geometry/elliptical_overlap.pyx photutils/geometry/rectangular_overlap.pyx photutils/geometry/tests/__init__.py photutils/geometry/tests/test_circular_overlap_grid.py photutils/geometry/tests/test_elliptical_overlap_grid.py photutils/geometry/tests/test_rectangular_overlap_grid.py photutils/isophote/__init__.py photutils/isophote/ellipse.py photutils/isophote/fitter.py photutils/isophote/geometry.py photutils/isophote/harmonics.py photutils/isophote/integrator.py photutils/isophote/isophote.py photutils/isophote/model.py photutils/isophote/sample.py photutils/isophote/tests/__init__.py photutils/isophote/tests/make_test_data.py photutils/isophote/tests/test_angles.py photutils/isophote/tests/test_ellipse.py photutils/isophote/tests/test_fitter.py photutils/isophote/tests/test_geometry.py photutils/isophote/tests/test_harmonics.py photutils/isophote/tests/test_integrator.py photutils/isophote/tests/test_isophote.py photutils/isophote/tests/test_model.py photutils/isophote/tests/test_regression.py photutils/isophote/tests/test_sample.py photutils/isophote/tests/data/M51_table.fits photutils/isophote/tests/data/README.rst photutils/isophote/tests/data/minimum_radius_test.fits photutils/isophote/tests/data/synth_highsnr_table.fits photutils/isophote/tests/data/synth_lowsnr_table.fits photutils/isophote/tests/data/synth_table.fits photutils/isophote/tests/data/synth_table_mean.fits photutils/isophote/tests/data/synth_table_mean.txt photutils/morphology/__init__.py photutils/morphology/core.py photutils/morphology/non_parametric.py photutils/morphology/tests/__init__.py photutils/morphology/tests/test_core.py photutils/morphology/tests/test_non_parametric.py photutils/profiles/__init__.py photutils/profiles/core.py photutils/profiles/curve_of_growth.py photutils/profiles/radial_profile.py photutils/profiles/tests/__init__.py photutils/profiles/tests/test_curve_of_growth.py photutils/profiles/tests/test_radial_profile.py photutils/psf/__init__.py photutils/psf/epsf.py photutils/psf/epsf_stars.py photutils/psf/functional_models.py photutils/psf/gridded_models.py photutils/psf/groupers.py photutils/psf/image_models.py photutils/psf/model_helpers.py photutils/psf/model_io.py photutils/psf/model_plotting.py photutils/psf/photometry.py photutils/psf/simulation.py photutils/psf/utils.py photutils/psf/matching/__init__.py photutils/psf/matching/fourier.py photutils/psf/matching/windows.py photutils/psf/matching/tests/__init__.py photutils/psf/matching/tests/test_fourier.py photutils/psf/matching/tests/test_windows.py photutils/psf/tests/__init__.py photutils/psf/tests/test_epsf.py photutils/psf/tests/test_epsf_stars.py photutils/psf/tests/test_functional_models.py photutils/psf/tests/test_gridded_models.py photutils/psf/tests/test_groupers.py photutils/psf/tests/test_image_models.py photutils/psf/tests/test_model_helpers.py photutils/psf/tests/test_photometry.py photutils/psf/tests/test_simulation.py photutils/psf/tests/test_utils.py photutils/psf/tests/data/STDPSF_ACSWFC_F814W_mock.fits photutils/psf/tests/data/STDPSF_NRCA1_F150W_mock.fits photutils/psf/tests/data/STDPSF_NRCSW_F150W_mock.fits photutils/psf/tests/data/STDPSF_WFC3UV_F814W_mock.fits photutils/psf/tests/data/STDPSF_WFPC2_F814W_mock.fits photutils/psf/tests/data/nircam_nrca1_f200w_fovp101_samp4_npsf16_mock.fits photutils/psf/tests/data/nircam_nrca1_f200w_fovp101_samp4_npsf4_mock.fits photutils/psf/tests/data/nircam_nrca5_f444w_fovp101_samp4_npsf4_mock.fits photutils/psf/tests/data/nircam_nrcb4_f150w_fovp101_samp4_npsf1_mock.fits photutils/segmentation/__init__.py photutils/segmentation/catalog.py photutils/segmentation/core.py photutils/segmentation/deblend.py photutils/segmentation/detect.py photutils/segmentation/finder.py photutils/segmentation/utils.py photutils/segmentation/tests/__init__.py photutils/segmentation/tests/test_catalog.py photutils/segmentation/tests/test_core.py photutils/segmentation/tests/test_deblend.py photutils/segmentation/tests/test_detect.py photutils/segmentation/tests/test_finder.py photutils/segmentation/tests/test_utils.py photutils/tests/__init__.py photutils/tests/helper.py photutils/utils/__init__.py photutils/utils/_convolution.py photutils/utils/_coords.py photutils/utils/_misc.py photutils/utils/_moments.py photutils/utils/_optional_deps.py photutils/utils/_parameters.py photutils/utils/_progress_bars.py photutils/utils/_quantity_helpers.py photutils/utils/_repr.py photutils/utils/_round.py photutils/utils/_stats.py photutils/utils/_wcs_helpers.py photutils/utils/colormaps.py photutils/utils/cutouts.py photutils/utils/depths.py photutils/utils/errors.py photutils/utils/exceptions.py photutils/utils/footprints.py photutils/utils/interpolation.py photutils/utils/tests/__init__.py photutils/utils/tests/test_colormaps.py photutils/utils/tests/test_convolution.py photutils/utils/tests/test_cutouts.py photutils/utils/tests/test_depths.py photutils/utils/tests/test_errors.py photutils/utils/tests/test_footprints.py photutils/utils/tests/test_interpolation.py photutils/utils/tests/test_misc.py photutils/utils/tests/test_moments.py photutils/utils/tests/test_parameters.py photutils/utils/tests/test_quantity_helpers.py photutils/utils/tests/test_round.py photutils/utils/tests/test_stats.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907483.0 photutils-2.2.0/photutils.egg-info/dependency_links.txt0000644000175100001660000000000114755160633022756 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907483.0 photutils-2.2.0/photutils.egg-info/not-zip-safe0000644000175100001660000000000114755160633021136 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907483.0 photutils-2.2.0/photutils.egg-info/requires.txt0000644000175100001660000000040314755160633021305 0ustar00runnerdockernumpy>=1.24 astropy>=5.3 scipy>=1.10 [all] matplotlib>=3.7 regions>=0.9 scikit-image>=0.20 gwcs>=0.20 bottleneck tqdm rasterio shapely [docs] photutils[all] sphinx sphinx_design sphinx-astropy[confv2]>=1.9.1 [test] pytest-astropy>=0.11 pytest-xdist>=2.5.0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907483.0 photutils-2.2.0/photutils.egg-info/top_level.txt0000644000175100001660000000001214755160633021433 0ustar00runnerdockerphotutils ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/pyproject.toml0000644000175100001660000001412714755160622016102 0ustar00runnerdocker[project] name = 'photutils' description = 'An Astropy package for source detection and photometry' readme = 'README.rst' license = {file = 'LICENSE.rst'} authors = [ {name = 'Photutils Developers', email = 'astropy.team@gmail.com'}, ] keywords = [ 'astronomy', 'astrophysics', 'photometry', 'aperture', 'psf', 'source detection', 'background', 'segmentation', 'centroids', 'isophote', 'morphology', 'radial profiles', ] classifiers = [ 'Intended Audience :: Science/Research', 'License :: OSI Approved :: BSD License', 'Natural Language :: English', 'Operating System :: OS Independent', 'Programming Language :: Cython', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Topic :: Scientific/Engineering :: Astronomy', ] dynamic = ['version'] requires-python = '>=3.11' dependencies = [ 'numpy>=1.24', 'astropy>=5.3', 'scipy>=1.10', ] [project.urls] Homepage = 'https://github.com/astropy/photutils' Documentation = 'https://photutils.readthedocs.io/en/stable/' [project.optional-dependencies] all = [ 'matplotlib>=3.7', 'regions>=0.9', 'scikit-image>=0.20', 'gwcs>=0.20', 'bottleneck', 'tqdm', 'rasterio', 'shapely', ] test = [ 'pytest-astropy>=0.11', 'pytest-xdist>=2.5.0', ] docs = [ 'photutils[all]', 'sphinx', 'sphinx_design', 'sphinx-astropy[confv2]>=1.9.1', ] [build-system] requires = [ 'setuptools>=61.2', 'setuptools_scm>=6.2', 'cython>=3.0.0,<4', 'numpy>=2.0.0', 'extension-helpers>=1', ] build-backend = 'setuptools.build_meta' [tool.extension-helpers] use_extension_helpers = true [tool.setuptools_scm] write_to = 'photutils/version.py' [tool.setuptools] zip-safe = false include-package-data = false [tool.setuptools.packages.find] namespaces = false [tool.setuptools.package-data] 'photutils' = [ 'CITATION.rst', ] 'photutils.datasets' = [ 'data/*', ] 'photutils.detection.tests' = [ 'data/*', ] 'photutils.isophote.tests' = [ 'data/*', ] 'photutils.psf.tests' = [ 'data/*', ] [tool.pytest.ini_options] minversion = 7.0 testpaths = [ 'photutils', 'docs', ] norecursedirs = [ 'docs/_build', 'extern', ] astropy_header = true doctest_plus = 'enabled' text_file_format = 'rst' addopts = [ '-ra', '--color=yes', '--doctest-rst', '--strict-config', '--strict-markers', ] log_cli_level = 'INFO' xfail_strict = true remote_data_strict = true filterwarnings = [ 'error', # turn warnings into exceptions 'ignore:numpy.ndarray size changed:RuntimeWarning', ] [tool.coverage.run] omit = [ 'photutils/_astropy_init*', 'photutils/conftest.py', 'photutils/*setup_package*', 'photutils/tests/*', 'photutils/*/tests/*', 'photutils/extern/*', 'photutils/version*', '*/photutils/_astropy_init*', '*/photutils/conftest.py', '*/photutils/*setup_package*', '*/photutils/tests/*', '*/photutils/*/tests/*', '*/photutils/extern/*', '*/photutils/version*', ] [tool.coverage.report] exclude_lines = [ 'pragma: no cover', 'except ImportError', 'raise AssertionError', 'raise NotImplementedError', 'def main\\(.*\\):', 'pragma: py{ignore_python_version}', 'def _ipython_key_completions_', ] [tool.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 'PY005', # ignore having a tests/ folder ] [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.numpydoc_validation] checks = [ 'all', # report on all checks, except the below 'ES01', # missing extended summary 'EX01', # missing "Examples" 'SA01', # missing "See Also" 'SA04', # missing "See Also" description 'RT02', # only type in "Returns" section (no name) 'SS06', # single-line summary 'RT01', # do not require return type for lazy properties ] # don't report on objects that match any of these regex; # remember to use single quotes for regex in TOML exclude = [ '__init__', '\._.*', # private functions/methods '^test_*', # test code '^conftest.*$', # pytest configuration # PR02: subclasses without __init__ 'Background$', 'BackgroundRMS$', 'RadialProfile$', # GL08: docstrings inherited from base classes '\.to_mask$', '\.calc_background$', '\.calc_background_rms$', '\.make_model_image$', '\.make_residual_image$', # GL08: property setters '\.normalization_correction$', '\.origin$', '\.fill_value$', '\.make_model_image$', '\.make_residual_image$', '\.cutout_center$', '\.data', # GL08: inner function '\.optimize_func$', ] [tool.docformatter] wrap-summaries = 72 pre-summary-newline = true make-summary-multi-line = true [tool.ruff] line-length = 79 [tool.ruff.lint.pylint] max-statements = 130 [tool.ruff.lint] select = ['ALL'] exclude = ["photutils/extern/*"] ignore = [ 'ANN', 'ARG002', 'ARG005', 'B008', 'B023', 'B028', 'BLE001', 'C901', 'COM812', 'D101', 'D102', 'D103', 'D105', 'D200', 'D205', 'D301', 'D401', 'D404', 'EM101', 'EM102', 'ERA001', 'FBT002', 'FIX002', 'I001', 'N803', 'N803', 'PD011', 'PERF203', 'PLR0912', 'PLR0913', 'PLR2004', 'PLW2901', 'PTH', 'Q000', 'RUF015', 'RUF100', 'S101', 'SLF001', 'TD002', 'TD003', 'TRY003', 'UP038', ] [tool.ruff.lint.per-file-ignores] '__init__.py' = ['D104', 'I'] 'docs/conf.py' = ['ERA001', 'INP001', 'TRY400'] 'model_io.py' = ['ARG001'] [tool.ruff.lint.pydocstyle] convention = 'numpy' [tool.codespell] ignore-words-list = """ ned, """ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1739907483.7289271 photutils-2.2.0/setup.cfg0000644000175100001660000000004614755160634015005 0ustar00runnerdocker[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1739907474.0 photutils-2.2.0/tox.ini0000644000175100001660000001040314755160622014472 0ustar00runnerdocker[tox] envlist = py{311,312,313}-test{,-alldeps,-devdeps,-oldestdeps,-devinfra}{,-cov} py{311,312,313}-test-numpy{126,200,210} build_docs linkcheck codestyle pep517 bandit isolated_build = true [testenv] # Suppress display of matplotlib plots generated during docs build setenv = MPLBACKEND=agg devdeps: PIP_EXTRA_INDEX_URL = https://pypi.anaconda.org/scientific-python-nightly-wheels/simple https://pypi.anaconda.org/liberfa/simple https://pypi.anaconda.org/astropy/simple # Pass through the following environment variables which may be needed # for the CI passenv = HOME,WINDIR,LC_ALL,LC_CTYPE,CC,CI # Run the tests in a temporary directory to make sure that we don't # import this package from the source tree changedir = .tmp/{envname} # tox environments are constructed with so-called 'factors' (or terms) # separated by hyphens, e.g., test-devdeps-cov. Lines below starting # with factor: will only take effect if that factor is included in the # environment name. To see a list of example environments that can be run, # along with a description, run: # # tox -l -v # description = run tests alldeps: with all optional dependencies devdeps: with the latest developer version of key dependencies devinfra: like devdeps but also dev version of infrastructure oldestdeps: with the oldest supported version of key dependencies cov: and test coverage numpy126: with numpy 1.26.* numpy200: with numpy 2.0.* numpy210: with numpy 2.1.* # The following provides some specific pinnings for key packages deps = cov: pytest-cov numpy126: numpy==1.26.* numpy200: numpy==2.0.* numpy210: numpy==2.1.* oldestdeps: numpy==1.24 oldestdeps: astropy==5.3 oldestdeps: scipy==1.10 oldestdeps: matplotlib==3.7 oldestdeps: scikit-image==0.20 oldestdeps: gwcs==0.20 oldestdeps: pytest-astropy==0.11 devdeps: numpy>=0.0.dev0 devdeps: scipy>=0.0.dev0 devdeps: scikit-image>=0.0.dev0 devdeps: matplotlib>=0.0.dev0 devdeps: pyerfa>=0.0.dev0 devdeps: astropy>=0.0.dev0 devdeps: git+https://github.com/spacetelescope/gwcs.git # Latest developer version of infrastructure packages. devinfra: git+https://github.com/pytest-dev/pytest.git devinfra: git+https://github.com/astropy/extension-helpers.git devinfra: git+https://github.com/astropy/pytest-doctestplus.git devinfra: git+https://github.com/astropy/pytest-remotedata.git devinfra: git+https://github.com/astropy/pytest-astropy-header.git devinfra: git+https://github.com/astropy/pytest-arraydiff.git devinfra: git+https://github.com/astropy/pytest-filter-subpackage.git devinfra: git+https://github.com/astropy/pytest-astropy.git # The following indicates which [project.optional-dependencies] from # pyproject.toml will be installed extras = test: test alldeps: all build_docs: docs install_command = !devdeps: python -I -m pip install devdeps: python -I -m pip install -v --pre commands = # Force numpy-dev after matplotlib downgrades it # (https://github.com/matplotlib/matplotlib/issues/26847) devdeps: python -m pip install --pre --upgrade --extra-index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple numpy pip freeze pytest --pyargs photutils {toxinidir}/docs \ cov: --cov photutils --cov-config={toxinidir}/pyproject.toml --cov-report xml:{toxinidir}/coverage.xml --cov-report term-missing \ {posargs} [testenv:build_docs] changedir = docs description = invoke sphinx-build to build the HTML docs extras = docs commands = pip freeze sphinx-build -W -b html . _build/html [testenv:linkcheck] changedir = docs description = check the links in the HTML docs extras = docs commands = pip freeze sphinx-build -W -b linkcheck . _build/html [testenv:codestyle] skip_install = true changedir = . description = check code style with flake8 deps = flake8 commands = flake8 photutils --count --max-line-length=79 [testenv:pep517] skip_install = true changedir = . description = PEP 517 deps = build twine commands = python -m build --sdist . twine check dist/* --strict [testenv:bandit] skip_install = true changedir = . description = security check with bandit deps = bandit commands = bandit -r photutils -c pyproject.toml