├── .github ├── dependabot.yml ├── release.yml └── workflows │ ├── ci_workflows.yml │ └── update-changelog.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGES.md ├── LICENSE ├── MANIFEST.in ├── README.rst ├── RELEASE_INSTRUCTIONS.md ├── docs ├── Makefile ├── celestial.rst ├── conf.py ├── footprints.rst ├── healpix.rst ├── index.rst ├── installation.rst ├── make.bat ├── mosaicking.rst ├── noncelestial.rst ├── performance.rst └── performance_mosaicking.rst ├── pyproject.toml ├── reproject ├── __init__.py ├── adaptive │ ├── __init__.py │ ├── core.py │ ├── deforest.pyx │ ├── high_level.py │ └── tests │ │ ├── __init__.py │ │ ├── reference │ │ ├── test_reproject_adaptive_2d.fits │ │ ├── test_reproject_adaptive_2d_rotated.fits │ │ ├── test_reproject_adaptive_roundtrip.fits │ │ └── test_reproject_adaptive_uncentered_jacobian.fits │ │ └── test_core.py ├── array_utils.py ├── common.py ├── conftest.py ├── healpix │ ├── __init__.py │ ├── core.py │ ├── high_level.py │ ├── tests │ │ ├── __init__.py │ │ ├── data │ │ │ ├── bayestar.fits.gz │ │ │ └── reference_result.fits │ │ ├── test_healpix.py │ │ └── test_utils.py │ └── utils.py ├── interpolation │ ├── __init__.py │ ├── core.py │ ├── high_level.py │ └── tests │ │ ├── __init__.py │ │ ├── reference │ │ ├── test_reproject_celestial_2d_gal2equ.fits │ │ ├── test_reproject_celestial_3d_equ2gal.fits │ │ ├── test_reproject_roundtrip.fits │ │ └── test_small_cutout.fits │ │ └── test_core.py ├── mosaicking │ ├── __init__.py │ ├── background.py │ ├── coadd.py │ ├── subset_array.py │ ├── tests │ │ ├── __init__.py │ │ ├── reference │ │ │ └── test_coadd_solar_map.fits │ │ ├── test_background.py │ │ ├── test_coadd.py │ │ ├── test_subset_array.py │ │ └── test_wcs_helpers.py │ └── wcs_helpers.py ├── spherical_intersect │ ├── __init__.py │ ├── _overlap.pyx │ ├── core.py │ ├── high_level.py │ ├── mNaN.h │ ├── overlap.py │ ├── overlapArea.c │ ├── overlapArea.h │ ├── overlapAreaPP.c │ ├── reproject_slice_c.c │ ├── reproject_slice_c.h │ ├── setup_package.py │ └── tests │ │ ├── __init__.py │ │ ├── test_high_level.py │ │ ├── test_overlap.py │ │ └── test_reproject.py ├── tests │ ├── __init__.py │ ├── data │ │ ├── aia_171_level1.asdf │ │ ├── aia_171_level1.fits │ │ ├── cube.hdr │ │ ├── equatorial_3d.fits │ │ ├── galactic_2d.fits │ │ ├── gc_eq.hdr │ │ ├── gc_ga.hdr │ │ ├── generate_asdf.py │ │ ├── image_with_distortion_map.fits │ │ ├── mwpan2_RGB_3600.hdr │ │ ├── secchi_l0_a.fits │ │ └── secchi_l0_b.fits │ ├── helpers.py │ ├── test_array_utils.py │ ├── test_high_level.py │ └── test_utils.py ├── utils.py └── wcs_utils.py └── tox.ini /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | actions: 9 | patterns: 10 | - "*" 11 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - pre-commit-ci 5 | categories: 6 | - title: Bug Fixes 7 | labels: 8 | - bug 9 | - title: New Features 10 | labels: 11 | - enhancement 12 | - title: Documentation 13 | labels: 14 | - Documentation 15 | - title: Other Changes 16 | labels: 17 | - "*" 18 | -------------------------------------------------------------------------------- /.github/workflows/ci_workflows.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | schedule: 5 | # run every day at 4am UTC 6 | - cron: '0 4 * * *' 7 | push: 8 | pull_request: 9 | workflow_dispatch: 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | 17 | tests: 18 | uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@8c0fde6f7e926df6ed7057255d29afa9c1ad5320 # v1.16.0 19 | with: 20 | envs: | 21 | - macos: py311-test-oldestdeps 22 | - macos: py312-test 23 | - macos: py313-test 24 | - linux: py311-test-oldestdeps 25 | - linux: py312-test 26 | runs-on: ubuntu-24.04-arm 27 | - linux: py313-test-devdeps 28 | - windows: py311-test-oldestdeps 29 | - windows: py312-test 30 | - windows: py313-test 31 | libraries: | 32 | apt: 33 | - libopenblas-dev 34 | coverage: 'codecov' 35 | 36 | publish: 37 | needs: tests 38 | uses: OpenAstronomy/github-actions-workflows/.github/workflows/publish.yml@8c0fde6f7e926df6ed7057255d29afa9c1ad5320 # v1.16.0 39 | with: 40 | test_extras: test 41 | test_command: pytest -p no:warnings --pyargs reproject 42 | targets: | 43 | - cp*-manylinux_x86_64 44 | - target: cp*-manylinux_aarch64 45 | runs-on: ubuntu-24.04-arm 46 | - cp*-macosx_x86_64 47 | - cp*-macosx_arm64 48 | - cp*-win_amd64 49 | 50 | # Developer wheels 51 | upload_to_anaconda: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }} 52 | anaconda_user: astropy 53 | anaconda_package: reproject 54 | anaconda_keep_n_latest: 10 55 | 56 | secrets: 57 | pypi_token: ${{ secrets.pypi_token }} 58 | anaconda_token: ${{ secrets.anaconda_token }} 59 | -------------------------------------------------------------------------------- /.github/workflows/update-changelog.yaml: -------------------------------------------------------------------------------- 1 | # This workflow takes the GitHub release notes an updates the changelog on the 2 | # main branch with the body of the release notes, thereby keeping a log in 3 | # the git repo of the changes. 4 | 5 | name: "Update Changelog" 6 | 7 | on: 8 | release: 9 | types: [released] 10 | 11 | jobs: 12 | update: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 18 | with: 19 | ref: main 20 | 21 | - name: Update Changelog 22 | uses: stefanzweifel/changelog-updater-action@a938690fad7edf25368f37e43a1ed1b34303eb36 # v1.12.0 23 | with: 24 | release-notes: ${{ github.event.release.body }} 25 | latest-version: ${{ github.event.release.name }} 26 | path-to-changelog: CHANGES.md 27 | 28 | - name: Commit updated CHANGELOG 29 | uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0 30 | with: 31 | branch: main 32 | commit_message: Update CHANGELOG 33 | file_pattern: CHANGES.md 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled files 2 | *.py[cod] 3 | *.a 4 | *.o 5 | *.so 6 | __pycache__ 7 | 8 | # Ignore .c files by default to avoid including generated code. If you want to 9 | # add a non-generated .c extension, use `git add -f filename.c`. 10 | *.c 11 | 12 | # Other generated files 13 | */version.py 14 | */cython_version.py 15 | htmlcov 16 | .coverage 17 | MANIFEST 18 | .ipynb_checkpoints 19 | 20 | # Sphinx 21 | docs/api 22 | docs/_build 23 | 24 | # Eclipse editor project files 25 | .project 26 | .pydevproject 27 | .settings 28 | 29 | # Pycharm editor project files 30 | .idea 31 | 32 | # Floobits project files 33 | .floo 34 | .flooignore 35 | 36 | # Packages/installer info 37 | *.egg 38 | *.egg-info 39 | dist 40 | build 41 | eggs 42 | parts 43 | bin 44 | var 45 | sdist 46 | develop-eggs 47 | .installed.cfg 48 | distribute-*.tar.gz 49 | 50 | # Other 51 | .cache 52 | .tox 53 | .*.sw[op] 54 | *~ 55 | .project 56 | .pydevproject 57 | .settings 58 | 59 | # Mac OSX 60 | .DS_Store 61 | 62 | .coverage* 63 | 64 | pip-wheel-metadata/ 65 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-case-conflict 7 | - id: check-yaml 8 | - id: debug-statements 9 | - id: end-of-file-fixer 10 | exclude: ".*(data.*|extern.*|licenses.*|.*.fits)$" 11 | - id: trailing-whitespace 12 | exclude: ".*(data.*|extern.*|licenses.*|.*.fits)$" 13 | 14 | # We list the warnings/errors to check for here rather than in setup.cfg because 15 | # we don't want these options to apply whenever anyone calls flake8 from the 16 | # command-line or their code editor - in this case all warnings/errors should be 17 | # checked for. The warnings/errors we check for here are: 18 | # E101 - mix of tabs and spaces 19 | # W191 - use of tabs 20 | # E201 - whitespace after '(' 21 | # E202 - whitespace before ')' 22 | # W291 - trailing whitespace 23 | # W292 - no newline at end of file 24 | # W293 - trailing whitespace 25 | # W391 - blank line at end of file 26 | # E111 - 4 spaces per indentation level 27 | # E112 - 4 spaces per indentation level 28 | # E113 - 4 spaces per indentation level 29 | # E301 - expected 1 blank line, found 0 30 | # E302 - expected 2 blank lines, found 0 31 | # E303 - too many blank lines (3) 32 | # E304 - blank lines found after function decorator 33 | # E305 - expected 2 blank lines after class or function definition 34 | # E306 - expected 1 blank line before a nested definition 35 | # E502 - the backslash is redundant between brackets 36 | # E722 - do not use bare except 37 | # E901 - SyntaxError or IndentationError 38 | # E902 - IOError 39 | # E999: SyntaxError -- failed to compile a file into an Abstract Syntax Tree 40 | # F822: undefined name in __all__ 41 | # F823: local variable name referenced before assignment 42 | - repo: https://github.com/PyCQA/flake8 43 | rev: 7.2.0 44 | hooks: 45 | - id: flake8 46 | args: 47 | [ 48 | "--count", 49 | "--select", 50 | "E101,W191,E201,E202,W291,W292,W293,W391,E111,E112,E113,E30,E502,E722,E901,E902,E999,F822,F823", 51 | ] 52 | exclude: ".*(data.*|extern.*|cextern)$" 53 | 54 | - repo: https://github.com/psf/black-pre-commit-mirror 55 | rev: 25.1.0 56 | hooks: 57 | - id: black 58 | 59 | - repo: https://github.com/numpy/numpydoc 60 | rev: v1.8.0 61 | hooks: 62 | - id: numpydoc-validation 63 | files: ".*(high_level|mosaicking).*$" 64 | exclude: ".*(tests.*)$" 65 | 66 | - repo: https://github.com/scientific-python/cookie 67 | rev: 2025.05.02 68 | hooks: 69 | - id: sp-repo-review 70 | 71 | - repo: https://github.com/codespell-project/codespell 72 | rev: v2.4.1 73 | hooks: 74 | - id: codespell 75 | args: ["--write-changes"] 76 | additional_dependencies: 77 | - tomli 78 | exclude: '.*\.(asdf)$' 79 | 80 | - repo: https://github.com/astral-sh/ruff-pre-commit 81 | rev: "v0.11.12" 82 | hooks: 83 | - id: ruff 84 | args: ["--fix", "--show-fixes"] 85 | 86 | ci: 87 | autofix_prs: false 88 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-22.04 4 | tools: 5 | python: "3.11" 6 | apt_packages: 7 | - graphviz 8 | 9 | sphinx: 10 | builder: html 11 | configuration: docs/conf.py 12 | fail_on_warning: true 13 | 14 | python: 15 | install: 16 | - method: pip 17 | extra_requirements: 18 | - docs 19 | path: . 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-16, Thomas P. Robitaille 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the Astropy project nor the names of its contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 21 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 22 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 23 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 24 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 25 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 26 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 27 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 28 | POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include CHANGES.md 3 | include LICENSE 4 | 5 | include pyproject.toml 6 | include setup.cfg 7 | 8 | include pyproject.toml 9 | 10 | recursive-include reproject *.pyx *.c 11 | recursive-include docs * 12 | 13 | prune build 14 | prune docs/_build 15 | prune docs/api 16 | 17 | global-exclude *.pyc *.o 18 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |Docs| |PyPI| |Build Status| |CI Status| |Coverage Status| |Powered by Astropy Badge| 2 | 3 | About 4 | ===== 5 | 6 | The `reproject` package is a Python package to reproject astronomical 7 | images using various techniques via a uniform interface. By 8 | *reprojection*, we mean the re-gridding of images from one world 9 | coordinate system to another (for example changing the pixel resolution, 10 | orientation, coordinate system). Currently, we have implemented 11 | reprojection of celestial images by interpolation (like 12 | `SWARP `__), by the adaptive and 13 | anti-aliased algorithm of `DeForest (2004) 14 | `_, and by finding the 15 | exact overlap between pixels on the celestial sphere (like `Montage 16 | `__). It can also reproject to/from 17 | HEALPIX projections by relying on the `astropy-healpix 18 | `__ package. 19 | 20 | For more information, including on how to install the package, see 21 | https://reproject.readthedocs.io 22 | 23 | .. figure:: https://github.com/astrofrog/reproject/raw/master/docs/images/index-4.png 24 | :alt: screenshot 25 | 26 | .. |Docs| image:: https://readthedocs.org/projects/reproject/badge/?version=latest 27 | :target: https://reproject.readthedocs.io/en/latest/?badge=latest 28 | .. |PyPI| image:: https://img.shields.io/pypi/v/reproject.svg 29 | :target: https://pypi.python.org/pypi/reproject 30 | .. |Build Status| image:: https://dev.azure.com/astropy-project/reproject/_apis/build/status/astropy.reproject?branchName=main 31 | :target: https://dev.azure.com/astropy-project/reproject/_build/latest?definitionId=3&branchName=main 32 | .. |CI Status| image:: https://github.com/astropy/reproject/workflows/CI/badge.svg 33 | :target: https://github.com/astropy/reproject/actions 34 | .. |Coverage Status| image:: https://codecov.io/gh/astropy/reproject/branch/main/graph/badge.svg 35 | :target: https://codecov.io/gh/astropy/reproject 36 | .. |Powered by Astropy Badge| image:: http://img.shields.io/badge/powered%20by-AstroPy-orange.svg?style=flat 37 | :target: https://astropy.org 38 | -------------------------------------------------------------------------------- /RELEASE_INSTRUCTIONS.md: -------------------------------------------------------------------------------- 1 | Making a New Reproject Release 2 | ============================== 3 | 4 | A new release of reproject is almost fully automated. 5 | As a maintainer it should be nice and simple to do, especially if all merged PRs 6 | have nice titles and are correctly labelled. 7 | 8 | Here is the process to follow to make a new release: 9 | 10 | * Go through all the PRs since the last release, make sure they have 11 | descriptive titles (these will become the changelog entry) and are labelled 12 | correctly. 13 | * Go to the GitHub releases interface and draft a new release, new tags should 14 | include the trailing `.0` on major releases. (Releases prior to 0.10.0 15 | didn't.) 16 | * Use the GitHub autochange log generator, this should use the configuration in 17 | `.github/release.yml` to make headings based on labels. 18 | * Edit the draft release notes as required, particularly to call out major 19 | changes at the top. 20 | * Publish the release. 21 | * Have a beverage of your choosing. (Note the wheels take a very long time to 22 | build). 23 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | #This is needed with git because git doesn't create a dir if it's empty 18 | $(shell [ -d "_static" ] || mkdir -p _static) 19 | 20 | help: 21 | @echo "Please use \`make ' where is one of" 22 | @echo " html to make standalone HTML files" 23 | @echo " dirhtml to make HTML files named index.html in directories" 24 | @echo " singlehtml to make a single large HTML file" 25 | @echo " pickle to make pickle files" 26 | @echo " json to make JSON files" 27 | @echo " htmlhelp to make HTML files and a HTML help project" 28 | @echo " qthelp to make HTML files and a qthelp project" 29 | @echo " devhelp to make HTML files and a Devhelp project" 30 | @echo " epub to make an epub" 31 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 32 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 33 | @echo " text to make text files" 34 | @echo " man to make manual pages" 35 | @echo " changes to make an overview of all changed/added/deprecated items" 36 | @echo " linkcheck to check all external links for integrity" 37 | 38 | clean: 39 | -rm -rf $(BUILDDIR) 40 | -rm -rf api 41 | -rm -rf generated 42 | 43 | html: 44 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 45 | @echo 46 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 47 | 48 | dirhtml: 49 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 50 | @echo 51 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 52 | 53 | singlehtml: 54 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 55 | @echo 56 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 57 | 58 | pickle: 59 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 60 | @echo 61 | @echo "Build finished; now you can process the pickle files." 62 | 63 | json: 64 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 65 | @echo 66 | @echo "Build finished; now you can process the JSON files." 67 | 68 | htmlhelp: 69 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 70 | @echo 71 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 72 | ".hhp project file in $(BUILDDIR)/htmlhelp." 73 | 74 | qthelp: 75 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 76 | @echo 77 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 78 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 79 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Astropy.qhcp" 80 | @echo "To view the help file:" 81 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Astropy.qhc" 82 | 83 | devhelp: 84 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 85 | @echo 86 | @echo "Build finished." 87 | @echo "To view the help file:" 88 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Astropy" 89 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Astropy" 90 | @echo "# devhelp" 91 | 92 | epub: 93 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 94 | @echo 95 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 96 | 97 | latex: 98 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 99 | @echo 100 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 101 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 102 | "(use \`make latexpdf' here to do that automatically)." 103 | 104 | latexpdf: 105 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 106 | @echo "Running LaTeX files through pdflatex..." 107 | make -C $(BUILDDIR)/latex all-pdf 108 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 109 | 110 | text: 111 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 112 | @echo 113 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 114 | 115 | man: 116 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 117 | @echo 118 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 119 | 120 | changes: 121 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 122 | @echo 123 | @echo "The overview file is in $(BUILDDIR)/changes." 124 | 125 | linkcheck: 126 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 127 | @echo 128 | @echo "Link check complete; look for any errors in the above output " \ 129 | "or in $(BUILDDIR)/linkcheck/output.txt." 130 | 131 | doctest: 132 | @echo "Run 'python setup.py test' in the root directory to run doctests " \ 133 | @echo "in the documentation." 134 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | 3 | import datetime 4 | import sys 5 | from importlib import metadata 6 | 7 | try: 8 | from sphinx_astropy.conf.v1 import * # noqa 9 | except ImportError: 10 | print("ERROR: the documentation requires the sphinx-astropy package to be installed") 11 | sys.exit(1) 12 | 13 | # -- General configuration ---------------------------------------------------- 14 | 15 | # By default, highlight as Python 3. 16 | highlight_language = "python3" 17 | 18 | # If your documentation needs a minimal Sphinx version, state it here. 19 | # needs_sphinx = '1.1' 20 | 21 | # List of patterns, relative to source directory, that match files and 22 | # directories to ignore when looking for source files. 23 | exclude_patterns.append("_templates") 24 | 25 | # This is added to the end of RST files - a good place to put substitutions to 26 | # be used globally. 27 | rst_epilog += """ 28 | """ 29 | 30 | # -- Project information ------------------------------------------------------ 31 | 32 | package_info = metadata.metadata("reproject") 33 | 34 | # This does not *have* to match the package name, but typically does 35 | project = package_info["Name"] 36 | author = package_info["Author"] 37 | copyright = "{}, {}".format(datetime.datetime.now().year, package_info["Author"]) 38 | 39 | # The version info for the project you're documenting, acts as replacement for 40 | # |version| and |release|, also used in various other places throughout the 41 | # built documents. 42 | 43 | # The short X.Y version. 44 | version = package_info["Version"].split("-", 1)[0] 45 | # The full version, including alpha/beta/rc tags. 46 | release = package_info["Version"] 47 | 48 | 49 | # -- Options for HTML output --------------------------------------------------- 50 | 51 | # A NOTE ON HTML THEMES 52 | # The global astropy configuration uses a custom theme, 'bootstrap-astropy', 53 | # which is installed along with astropy. A different theme can be used or 54 | # the options for this theme can be modified by overriding some of the 55 | # variables set in the global configuration. The variables set in the 56 | # global configuration are listed below, commented out. 57 | 58 | html_theme_options = { 59 | "logotext1": "re", # white, semi-bold 60 | "logotext2": "project", # orange, light 61 | "logotext3": ":docs", # white, light 62 | } 63 | 64 | # Add any paths that contain custom themes here, relative to this directory. 65 | # To use a different custom theme, add the directory containing the theme. 66 | # html_theme_path = [] 67 | 68 | # The theme to use for HTML and HTML Help pages. See the documentation for 69 | # a list of builtin themes. To override the custom theme, set this to the 70 | # name of a builtin theme or the name of a custom theme in html_theme_path. 71 | # html_theme = None 72 | 73 | # Custom sidebar templates, maps document names to template names. 74 | # html_sidebars = {} 75 | 76 | # The name of an image file (within the static path) to use as favicon of the 77 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 78 | # pixels large. 79 | # html_favicon = '' 80 | 81 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 82 | # using the given strftime format. 83 | # html_last_updated_fmt = '' 84 | 85 | # The name for this set of Sphinx documents. If None, it defaults to 86 | # " v documentation". 87 | html_title = f"{project} v{release}" 88 | 89 | # Output file base name for HTML help builder. 90 | htmlhelp_basename = project + "doc" 91 | 92 | 93 | # -- Options for LaTeX output -------------------------------------------------- 94 | 95 | # Grouping the document tree into LaTeX files. List of tuples 96 | # (source start file, target name, title, author, documentclass [howto/manual]). 97 | latex_documents = [("index", project + ".tex", project + " Documentation", author, "manual")] 98 | 99 | 100 | # -- Options for manual page output -------------------------------------------- 101 | 102 | # One entry per manual page. List of tuples 103 | # (source start file, name, description, authors, manual section). 104 | man_pages = [("index", project.lower(), project + " Documentation", [author], 1)] 105 | 106 | 107 | ## -- Options for the edit_on_github extension ---------------------------------------- 108 | 109 | nitpicky = True 110 | 111 | plot_rcparams = {} 112 | plot_rcparams["figure.figsize"] = (8, 6) 113 | plot_rcparams["savefig.facecolor"] = "none" 114 | plot_rcparams["savefig.bbox"] = "tight" 115 | plot_rcparams["axes.labelsize"] = "large" 116 | plot_rcparams["figure.subplot.hspace"] = 0.5 117 | 118 | plot_apply_rcparams = True 119 | plot_html_show_source_link = False 120 | plot_formats = ["png", "svg", "pdf"] 121 | -------------------------------------------------------------------------------- /docs/footprints.rst: -------------------------------------------------------------------------------- 1 | **************** 2 | Footprint arrays 3 | **************** 4 | 5 | As described for example in :doc:`celestial`, all reprojection functions in 6 | this package return a data array (the reprojected values) and a footprint 7 | array, which shows which pixels in the new reprojected data fell inside the 8 | original image. 9 | 10 | For interpolation-based algorithms, the footprint array can either take a value 11 | of 0 or 1, but for the 'exact' algorithm based on spherical polygon 12 | intersection, and in future for the drizzle algorithm, we can actually find out 13 | what fraction of the new pixels overlapped with the original image. 14 | 15 | To demonstrate this, we take the same example as in the :ref:`quickstart` guide, 16 | but this time we reproject the array using both the interpolation and 'exact' 17 | algorithms, and look closely at what is happening near the boundaries. We start 18 | off again by reading in the data: 19 | 20 | .. plot:: 21 | :include-source: 22 | :context: reset 23 | :nofigs: 24 | 25 | from astropy.io import fits 26 | from astropy.utils.data import get_pkg_data_filename 27 | hdu1 = fits.open(get_pkg_data_filename('galactic_center/gc_2mass_k.fits'))[0] 28 | hdu2 = fits.open(get_pkg_data_filename('galactic_center/gc_msx_e.fits'))[0] 29 | 30 | As before, we now reproject the MSX image to be in the same projection as the 31 | 2MASS image, but we do this with two algorithms: 32 | 33 | .. plot:: 34 | :include-source: 35 | :context: 36 | :nofigs: 37 | 38 | from reproject import reproject_interp, reproject_exact 39 | array_interp, footprint_interp = reproject_interp(hdu2, hdu1.header) 40 | array_exact, footprint_exact = reproject_exact(hdu2, hdu1.header) 41 | 42 | Finally, we can visualize the footprint, zooming in to one of the edges: 43 | 44 | .. plot:: 45 | :include-source: 46 | :context: 47 | 48 | import matplotlib.pyplot as plt 49 | 50 | ax1 = plt.subplot(1,2,1) 51 | ax1.imshow(footprint_interp, origin='lower', 52 | vmin=0, vmax=1.5, interpolation='nearest') 53 | ax1.set_xlim(90, 105) 54 | ax1.set_ylim(90, 105) 55 | ax1.set_title('Footprint (interpolation)') 56 | 57 | ax2 = plt.subplot(1,2,2) 58 | ax2.imshow(footprint_exact, origin='lower', 59 | vmin=0, vmax=1.5, interpolation='nearest') 60 | ax2.set_xlim(90, 105) 61 | ax2.set_ylim(90, 105) 62 | ax2.set_title('Footprint (exact)') 63 | 64 | As you can see, the footprint for the exact mode correctly shows that some of 65 | the new pixels had a fractional overlap with the original image. Note however 66 | that this comes at a computational cost, since the exact mode can be 67 | significantly slower than interpolation. 68 | -------------------------------------------------------------------------------- /docs/healpix.rst: -------------------------------------------------------------------------------- 1 | ************** 2 | HEALPIX images 3 | ************** 4 | 5 | Images can also be stored using the HEALPIX representation, and the 6 | *reproject* package includes two functions, 7 | :func:`~reproject.reproject_from_healpix` and 8 | :func:`~reproject.reproject_to_healpix`, which can be used to reproject 9 | from/to HEALPIX representations (these functions are wrappers around 10 | functionality provided by the `astropy-healpix `_ 11 | package). These functions do the reprojection using interpolation (and the 12 | order can be specified using the ``order`` argument). The functions can be 13 | imported with: 14 | 15 | .. plot:: 16 | :include-source: 17 | :nofigs: 18 | :context: reset 19 | 20 | from reproject import reproject_from_healpix, reproject_to_healpix 21 | 22 | The :func:`~reproject.reproject_from_healpix` function takes either a 23 | filename, a FITS Table HDU object, or a tuple containing a 1-D array and a 24 | coordinate frame given as an Astropy :class:`~astropy.coordinates.BaseCoordinateFrame` 25 | instance or a string. The target 26 | projection should be given either as a WCS object (which required you to also 27 | specify the output shape using ``shape_out``) or as a FITS 28 | :class:`~astropy.io.fits.Header` object. 29 | 30 | To demonstrate these functions, we can download an example HEALPIX map which 31 | is a posterior probability distribution map from the `LIGO project 32 | `_: 33 | 34 | .. plot:: 35 | :include-source: 36 | :nofigs: 37 | :context: 38 | 39 | from astropy.utils.data import get_pkg_data_filename 40 | filename_ligo = get_pkg_data_filename('allsky/ligo_simulated.fits.gz') 41 | 42 | We can then read in this dataset using Astropy (note that we access HDU 1 43 | because HEALPIX data is stored as a binary table which cannot be in HDU 0): 44 | 45 | .. plot:: 46 | :include-source: 47 | :nofigs: 48 | :context: 49 | 50 | from astropy.io import fits 51 | hdu_ligo = fits.open(filename_ligo)[1] 52 | 53 | We now define a header using the 54 | `Mollweide `_ projection: 55 | 56 | .. plot:: 57 | :include-source: 58 | :nofigs: 59 | :context: 60 | 61 | target_header = fits.Header.fromstring(""" 62 | NAXIS = 2 63 | NAXIS1 = 480 64 | NAXIS2 = 240 65 | CTYPE1 = 'RA---MOL' 66 | CRPIX1 = 240.5 67 | CRVAL1 = 180.0 68 | CDELT1 = -0.675 69 | CUNIT1 = 'deg ' 70 | CTYPE2 = 'DEC--MOL' 71 | CRPIX2 = 120.5 72 | CRVAL2 = 0.0 73 | CDELT2 = 0.675 74 | CUNIT2 = 'deg ' 75 | COORDSYS= 'icrs ' 76 | """, sep='\n') 77 | 78 | All of the following are examples of valid ways of reprojecting the HEALPIX LIGO data onto the Mollweide projection: 79 | 80 | * With an input filename and a target header:: 81 | 82 | array, footprint = reproject_from_healpix(filename_ligo, target_header) 83 | 84 | * With an input filename and a target wcs and shape:: 85 | 86 | from astropy.wcs import WCS 87 | target_wcs = WCS(target_header) 88 | array, footprint = reproject_from_healpix(filename_ligo, target_wcs, 89 | shape_out=(240,480)) 90 | 91 | * With an input array (and associated coordinate system as a string) and a target header:: 92 | 93 | data = hdu_ligo.data['PROB'] 94 | array, footprint = reproject_from_healpix((data, 'icrs'), 95 | target_header, nested=True) 96 | 97 | Note that in this case we have to be careful to specify whether the pixels 98 | are in nested (``nested=True``) or ring (``nested=False``) order. 99 | 100 | * With an input array (and associated coordinate system) and a target header:: 101 | 102 | from astropy.coordinates import FK5 103 | array, footprint = reproject_from_healpix((data, FK5(equinox='J2010')), 104 | target_header, nested=True) 105 | 106 | The resulting map is the following: 107 | 108 | .. plot:: 109 | :nofigs: 110 | :context: 111 | 112 | array, footprint = reproject_from_healpix(filename_ligo, target_header) 113 | 114 | .. plot:: 115 | :include-source: 116 | :context: 117 | 118 | from astropy.wcs import WCS 119 | import matplotlib.pyplot as plt 120 | from astropy.visualization.wcsaxes.frame import EllipticalFrame 121 | 122 | ax = plt.subplot(1,1,1, projection=WCS(target_header), 123 | frame_class=EllipticalFrame) 124 | ax.imshow(array, vmin=0, vmax=1.e-8) 125 | ax.coords.grid(color='white') 126 | ax.coords['ra'].set_ticklabel(color='white') 127 | 128 | On the other hand, the :func:`~reproject.reproject_to_healpix` function takes 129 | input data in the same form as :func:`~reproject.reproject_interp` 130 | (see :ref:`interpolation`) for the first argument, and a coordinate frame as the 131 | second argument, either as a string or as a 132 | :class:`~astropy.coordinates.BaseCoordinateFrame` instance e.g.:: 133 | 134 | array, footprint = reproject_to_healpix((array, target_header), 'galactic', nside=128) 135 | 136 | The array returned is a 1-D array which can be stored in a HEALPIX FITS file. 137 | We can use the `~astropy.table.Table` object to easily write the array to a 138 | HEALPix FITS file:: 139 | 140 | from astropy.table import Table 141 | t = Table() 142 | t['flux'] = array 143 | t.meta['ORDERING'] = 'RING' 144 | t.meta['COORDSYS'] = 'G' 145 | t.meta['NSIDE'] = 128 146 | t.meta['INDXSCHM'] = 'IMPLICIT' 147 | t.write('healpix_map.fits') 148 | 149 | .. note:: When converting to a HEALPIX array, it is important to be aware 150 | that the order of the array matters (nested or ring). The 151 | :func:`~reproject.reproject_to_healpix` function takes a ``nested`` 152 | argument that defaults to `False`, hence why we set ``ORDERING`` to 153 | ``'RING'``. 154 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. _reprojection: 2 | 3 | ******************************* 4 | Image reprojection (resampling) 5 | ******************************* 6 | 7 | Introduction 8 | ============ 9 | 10 | The *reproject* package implements image reprojection (resampling) methods 11 | for astronomical images and more generally n-dimensional data. These assume 12 | that the WCS information contained in the data are correct. This package does 13 | **not** do image registration, which is the process of aligning images where 14 | one or more images may have incorrect or missing WCS. 15 | 16 | You can install *reproject* with `pip `_:: 17 | 18 | pip install reproject 19 | 20 | or with `conda `_:: 21 | 22 | conda install -c conda-forge reproject 23 | 24 | More detailed installation instructions can be found in :ref:`installation`. 25 | 26 | .. _quickstart: 27 | 28 | Quick start 29 | =========== 30 | 31 | A common use case is that you have two FITS images, and want to reproject one 32 | to the same header as the other. This can easily be done with the *reproject* 33 | package, and we demonstrate this in the following example. We start off by 34 | downloading two example images from `http://data.astropy.org `_, 35 | namely a 2MASS K-band image and an MSX band E image of the Galactic center: 36 | 37 | .. plot:: 38 | :include-source: 39 | :nofigs: 40 | :context: reset 41 | 42 | from astropy.io import fits 43 | from astropy.utils.data import get_pkg_data_filename 44 | hdu1 = fits.open(get_pkg_data_filename('galactic_center/gc_2mass_k.fits'))[0] 45 | hdu2 = fits.open(get_pkg_data_filename('galactic_center/gc_msx_e.fits'))[0] 46 | 47 | We can examine the two images (this makes use of the 48 | `wcsaxes `_ package behind the scenes): 49 | 50 | .. plot:: 51 | :include-source: 52 | :context: 53 | 54 | from astropy.wcs import WCS 55 | import matplotlib.pyplot as plt 56 | 57 | ax1 = plt.subplot(1,2,1, projection=WCS(hdu1.header)) 58 | ax1.imshow(hdu1.data, origin='lower', vmin=-100., vmax=2000.) 59 | ax1.coords['ra'].set_axislabel('Right Ascension') 60 | ax1.coords['dec'].set_axislabel('Declination') 61 | ax1.set_title('2MASS K-band') 62 | 63 | ax2 = plt.subplot(1,2,2, projection=WCS(hdu2.header)) 64 | ax2.imshow(hdu2.data, origin='lower', vmin=-2.e-4, vmax=5.e-4) 65 | ax2.coords['glon'].set_axislabel('Galactic Longitude') 66 | ax2.coords['glat'].set_axislabel('Galactic Latitude') 67 | ax2.coords['glat'].set_axislabel_position('r') 68 | ax2.coords['glat'].set_ticklabel_position('r') 69 | ax2.set_title('MSX band E') 70 | 71 | We now reproject the MSX image to be in the same projection as the 2MASS image: 72 | 73 | .. plot:: 74 | :include-source: 75 | :nofigs: 76 | :context: 77 | 78 | from reproject import reproject_interp 79 | array, footprint = reproject_interp(hdu2, hdu1.header) 80 | 81 | The :func:`~reproject.reproject_interp` function above returns the 82 | reprojected array as well as an array that provides information on the 83 | footprint of the first image in the new reprojected image plane (essentially 84 | which pixels in the new image had a corresponding pixel in the old image). We 85 | can now visualize the reprojected data and footprint: 86 | 87 | .. plot:: 88 | :include-source: 89 | :context: 90 | 91 | from astropy.wcs import WCS 92 | import matplotlib.pyplot as plt 93 | 94 | ax1 = plt.subplot(1,2,1, projection=WCS(hdu1.header)) 95 | ax1.imshow(array, origin='lower', vmin=-2.e-4, vmax=5.e-4) 96 | ax1.coords['ra'].set_axislabel('Right Ascension') 97 | ax1.coords['dec'].set_axislabel('Declination') 98 | ax1.set_title('Reprojected MSX band E image') 99 | 100 | ax2 = plt.subplot(1,2,2, projection=WCS(hdu1.header)) 101 | ax2.imshow(footprint, origin='lower', vmin=0, vmax=1.5) 102 | ax2.coords['ra'].set_axislabel('Right Ascension') 103 | ax2.coords['dec'].set_axislabel('Declination') 104 | ax2.coords['dec'].set_axislabel_position('r') 105 | ax2.coords['dec'].set_ticklabel_position('r') 106 | ax2.set_title('MSX band E image footprint') 107 | 108 | We can then write out the image to a new FITS file. Note that, as for 109 | plotting, we can use the header from the 2MASS image since both images are 110 | now in the same projection: 111 | 112 | .. plot:: 113 | :include-source: 114 | :nofigs: 115 | :context: 116 | 117 | fits.writeto('msx_on_2mass_header.fits', array, hdu1.header, overwrite=True) 118 | 119 | The *reproject* package supports a number of different algorithms for 120 | reprojection (interpolation, flux-conserving reprojection, etc.) and 121 | different types of data (images, spectral cubes, HEALPIX images, etc.). For 122 | more information, we encourage you to read the full documentation below! 123 | 124 | Documentation 125 | ============= 126 | 127 | The reproject package consists of a few high-level functions to do 128 | reprojection using different algorithms, which depend on the type of data 129 | that you want to reproject. 130 | 131 | .. toctree:: 132 | :maxdepth: 2 133 | 134 | installation 135 | celestial 136 | healpix 137 | noncelestial 138 | footprints 139 | mosaicking 140 | performance 141 | performance_mosaicking 142 | 143 | Reference/API 144 | ============= 145 | 146 | .. automodapi:: reproject 147 | :no-inheritance-diagram: 148 | 149 | .. automodapi:: reproject.mosaicking 150 | :no-inheritance-diagram: 151 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | ******************** 4 | Installing reproject 5 | ******************** 6 | 7 | Requirements 8 | ============ 9 | 10 | This package has the following dependencies: 11 | 12 | * `Python `__ 3.9 or later 13 | 14 | * `Numpy `__ 1.20 or later 15 | 16 | * `Astropy `__ 5.0 or later 17 | 18 | * `Scipy `__ 1.5 or later 19 | 20 | * `astropy-healpix `_ 0.6 or later for HEALPIX image reprojection 21 | 22 | * `dask `_ 2021.8 or later 23 | 24 | * `zarr `_ 25 | 26 | * `fsspec `_ 27 | 28 | and the following optional dependencies: 29 | 30 | * `shapely `_ 1.6 or later for some of the mosaicking functionality 31 | 32 | Installation 33 | ============ 34 | 35 | Using pip 36 | --------- 37 | 38 | To install *reproject* with `pip `_, 39 | run:: 40 | 41 | pip install reproject 42 | 43 | Using conda 44 | ----------- 45 | 46 | To install *reproject* with `conda `_, run:: 47 | 48 | conda install -c conda-forge reproject 49 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | if NOT "%PAPER%" == "" ( 11 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 12 | ) 13 | 14 | if "%1" == "" goto help 15 | 16 | if "%1" == "help" ( 17 | :help 18 | echo.Please use `make ^` where ^ is one of 19 | echo. html to make standalone HTML files 20 | echo. dirhtml to make HTML files named index.html in directories 21 | echo. singlehtml to make a single large HTML file 22 | echo. pickle to make pickle files 23 | echo. json to make JSON files 24 | echo. htmlhelp to make HTML files and a HTML help project 25 | echo. qthelp to make HTML files and a qthelp project 26 | echo. devhelp to make HTML files and a Devhelp project 27 | echo. epub to make an epub 28 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 29 | echo. text to make text files 30 | echo. man to make manual pages 31 | echo. changes to make an overview over all changed/added/deprecated items 32 | echo. linkcheck to check all external links for integrity 33 | echo. doctest to run all doctests embedded in the documentation if enabled 34 | goto end 35 | ) 36 | 37 | if "%1" == "clean" ( 38 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 39 | del /q /s %BUILDDIR%\* 40 | goto end 41 | ) 42 | 43 | if "%1" == "html" ( 44 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 45 | if errorlevel 1 exit /b 1 46 | echo. 47 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 48 | goto end 49 | ) 50 | 51 | if "%1" == "dirhtml" ( 52 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 53 | if errorlevel 1 exit /b 1 54 | echo. 55 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 56 | goto end 57 | ) 58 | 59 | if "%1" == "singlehtml" ( 60 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 61 | if errorlevel 1 exit /b 1 62 | echo. 63 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 64 | goto end 65 | ) 66 | 67 | if "%1" == "pickle" ( 68 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 69 | if errorlevel 1 exit /b 1 70 | echo. 71 | echo.Build finished; now you can process the pickle files. 72 | goto end 73 | ) 74 | 75 | if "%1" == "json" ( 76 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished; now you can process the JSON files. 80 | goto end 81 | ) 82 | 83 | if "%1" == "htmlhelp" ( 84 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished; now you can run HTML Help Workshop with the ^ 88 | .hhp project file in %BUILDDIR%/htmlhelp. 89 | goto end 90 | ) 91 | 92 | if "%1" == "qthelp" ( 93 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 94 | if errorlevel 1 exit /b 1 95 | echo. 96 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 97 | .qhcp project file in %BUILDDIR%/qthelp, like this: 98 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Astropy.qhcp 99 | echo.To view the help file: 100 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Astropy.ghc 101 | goto end 102 | ) 103 | 104 | if "%1" == "devhelp" ( 105 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 106 | if errorlevel 1 exit /b 1 107 | echo. 108 | echo.Build finished. 109 | goto end 110 | ) 111 | 112 | if "%1" == "epub" ( 113 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 117 | goto end 118 | ) 119 | 120 | if "%1" == "latex" ( 121 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 122 | if errorlevel 1 exit /b 1 123 | echo. 124 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 125 | goto end 126 | ) 127 | 128 | if "%1" == "text" ( 129 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 130 | if errorlevel 1 exit /b 1 131 | echo. 132 | echo.Build finished. The text files are in %BUILDDIR%/text. 133 | goto end 134 | ) 135 | 136 | if "%1" == "man" ( 137 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 141 | goto end 142 | ) 143 | 144 | if "%1" == "changes" ( 145 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.The overview file is in %BUILDDIR%/changes. 149 | goto end 150 | ) 151 | 152 | if "%1" == "linkcheck" ( 153 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Link check complete; look for any errors in the above output ^ 157 | or in %BUILDDIR%/linkcheck/output.txt. 158 | goto end 159 | ) 160 | 161 | if "%1" == "doctest" ( 162 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 163 | if errorlevel 1 exit /b 1 164 | echo. 165 | echo.Testing of doctests in the sources finished, look at the ^ 166 | results in %BUILDDIR%/doctest/output.txt. 167 | goto end 168 | ) 169 | 170 | :end 171 | -------------------------------------------------------------------------------- /docs/noncelestial.rst: -------------------------------------------------------------------------------- 1 | ************************* 2 | Non-celestial images/data 3 | ************************* 4 | 5 | Non-celestial images/data are not currently supported but will be added in future releases. 6 | -------------------------------------------------------------------------------- /docs/performance.rst: -------------------------------------------------------------------------------- 1 | **************************************************** 2 | Optimizing performance for single-image reprojection 3 | **************************************************** 4 | 5 | Disabling coordinate transformation round-tripping 6 | ================================================== 7 | 8 | For the interpolation and adaptive algorithms, an optional third argument, 9 | ``roundtrip_coords`` is accepted. By default, after coordinates are transformed 10 | from the output plane to the input plane, the input-plane coordinates are 11 | transformed back to the output plane to ensure that the transformation is 12 | defined in both directions. This doubles the amount of 13 | coordinate-transformation work to be done. In speed-critical situations, where 14 | it is known that the coordinate transformation is defined everywhere, this 15 | extra work can be disabled by setting ``roundtrip_coords=False``. (Note that 16 | this is not a question of whether each output pixel maps to an existing *pixel* 17 | in the input image and vice-versa, but whether it maps to a valid *coordinate* 18 | in the coordinate system of the input image---regardless of whether that 19 | coordinate falls within the bounds of the input image.) 20 | 21 | Disabling returning the footprint 22 | ================================= 23 | 24 | If you don't need the output footprint after reprojection, you can set 25 | ``return_footprint=False`` to return only the reprojected array. This can save 26 | memory and in some cases computing time: 27 | 28 | .. doctest-skip:: 29 | 30 | >>> array = reproject_interp(..., return_footprint=False) 31 | 32 | Using memory-mapped output arrays 33 | ================================= 34 | 35 | If you are dealing with a large dataset to reproject, you may be want to 36 | write the reprojected array (and optionally the footprint) to an array of your choice, such as for example 37 | a memory-mapped array. For example: 38 | 39 | .. doctest-skip:: 40 | 41 | >>> header_out = fits.Header.fromtextfile('cube_header_gal.hdr') 42 | >>> shape = (header_out['NAXIS3'], header_out['NAXIS2'], header_out['NAXIS1']) 43 | >>> array_out = np.memmap(filename='output.np', mode='w+', 44 | ... shape=shape, dtype='float32') 45 | >>> hdu = fits.open('cube_file.fits') 46 | >>> reproject_interp(hdu, header_out, output_array=array_out, 47 | ... return_footprint=False) 48 | 49 | After the call to :func:`~reproject.reproject_interp`, ``array_out`` will contain the reprojected values. 50 | If you set up a memory-mapped array for the footprint you could also do: 51 | 52 | .. doctest-skip:: 53 | 54 | 55 | >>> reproject_interp(hdu, header_out, output_array=array_out, 56 | ... output_footprint=footprint_out) 57 | 58 | If you are dealing with FITS files, you can skip the numpy memmap step and use `FITS large file creation 59 | `_: 60 | 61 | .. doctest-skip:: 62 | 63 | >>> header_out = fits.Header.fromtextfile('cube_header_gal.hdr') 64 | >>> header_out.tofile('new_cube.fits') 65 | >>> shape = tuple(header_out['NAXIS{0}'.format(ii)] for ii in range(1, header_out['NAXIS']+1)) 66 | >>> with open('new_cube.fits', 'rb+') as fobj: 67 | ... fobj.seek(len(header_out.tostring()) + (np.product(shape) * np.abs(header_out['BITPIX']//8)) - 1) 68 | ... fobj.write(b'\0') 69 | >>> hdu_out = fits.open('new_cube.fits', mode='update') 70 | >>> rslt = reproject.reproject_interp(hdu, header_out, output_array=hdu_out[0].data, 71 | ... return_footprint=False) 72 | >>> hdu_out.flush() 73 | 74 | .. _broadcasting: 75 | 76 | Multiple images with the same coordinates 77 | ========================================= 78 | 79 | If you have multiple images with the exact same coordinate system (e.g. a raw 80 | image and a corresponding processed image) and want to reproject both to the 81 | same output frame, it is faster to compute the coordinate mapping between input 82 | and output pixels only once and reuse this mapping for each reprojection. This 83 | is supported by passing multiple input images as an additional dimension in the 84 | input data array. When the input array contains more dimensions than the input 85 | WCS describes, the extra leading dimensions are taken to represent separate 86 | images with the same coordinates, and the reprojection loops over those 87 | dimensions after computing the pixel mapping. For example: 88 | 89 | .. doctest-skip:: 90 | >>> raw_image, header_in = fits.getdata('raw_image.fits', header=True) 91 | >>> bg_subtracted_image = fits.getdata('background_subtracted_image.fits') 92 | >>> header_out = # Prepare your desired output projection here 93 | >>> # Combine the two images into one array 94 | >>> image_stack = np.stack((raw_image, bg_subtracted_image)) 95 | >>> # We provide a header that describes 2 WCS dimensions, but our input 96 | >>> # array shape is (2, ny, nx)---the 'extra' first dimension represents 97 | >>> # separate images sharing the same coordinates. 98 | >>> reprojected, footprint = reproject.reproject_adaptive( 99 | ... (image_stack, header_in), header_out) 100 | >>> # The shape of `reprojected` is (2, ny', nx') 101 | >>> reprojected_raw, reprojected_bg_subtracted = reprojected[0], reprojected[1] 102 | 103 | For :func:`~reproject.reproject_interp` and 104 | :func:`~reproject.reproject_adaptive`, this is approximately twice as fast as 105 | reprojecting the two images separately. For :func:`~reproject.reproject_exact` 106 | the savings are much less significant, as producing the coordinate mapping is a 107 | much smaller portion of the total runtime for this algorithm. 108 | 109 | When the output coordinates are provided as a WCS and a ``shape_out`` tuple, 110 | ``shape_out`` may describe the output shape of a single image, in which case 111 | the extra leading dimensions are prepended automatically, or it may include the 112 | extra dimensions, in which case the size of the extra dimensions must match 113 | those of the input data exactly. 114 | 115 | While the reproject functions can accept the name of a FITS file as input, from 116 | which the input data and coordinates are loaded automatically, this multi-image 117 | reprojection feature does not support loading multiple images automatically 118 | from multiple HDUs within one FITS file, as it would be difficult to verify 119 | automatically that the HDUs contain the same exact coordinates. If multiple 120 | HDUs do share coordinates and are to be reprojected together, they must be 121 | separately loaded and combined into a single input array (e.g. using 122 | ``np.stack`` as in the above example). 123 | 124 | Chunk by chunk reprojection 125 | =========================== 126 | 127 | .. testsetup:: 128 | 129 | >>> import numpy as np 130 | >>> import dask.array as da 131 | >>> input_array = np.random.random((1024, 1024)) 132 | >>> dask_array = da.from_array(input_array, chunks=(128, 128)) 133 | >>> from astropy.wcs import WCS 134 | >>> wcs_in = WCS(naxis=2) 135 | >>> wcs_out = WCS(naxis=2) 136 | 137 | When calling one of the reprojection functions, you can specify a block size to use for the 138 | reprojection, and this is used to iterate over chunks in the output array in 139 | chunks. For instance, if you pass in a (1024, 1024) array and specify that the 140 | shape of the output should be a (2048, 2048) array (e.g., via ``shape_out``), 141 | then if you set ``block_size=(256, 256)``:: 142 | 143 | >>> from reproject import reproject_interp 144 | >>> input_array.shape 145 | (1024, 1024) 146 | >>> array, footprint = reproject_interp((input_array, wcs_in), wcs_out, 147 | ... shape_out=(2048, 2048), block_size=(256, 256)) 148 | 149 | the reprojection will be done in 64 separate output chunks. Note however that 150 | this does not break up the input array into chunks since in the general case any 151 | input pixel may contribute to any output pixel. 152 | 153 | .. _multithreading: 154 | 155 | Multi-threaded reprojection 156 | =========================== 157 | 158 | By default, the iteration over the output chunks is done in a single 159 | process/thread, but you may specify ``parallel=True`` to process these in 160 | parallel. If you do this, reproject will use multiple threads to parallelize the 161 | computation. If you specify ``parallel=True``, then ``block_size`` will be 162 | automatically set to a sensible default, but you can also set ``block_size`` 163 | manually for more control. Note that you can also set ``parallel=`` to an 164 | integer to indicate the number of threads to use. 165 | 166 | Input dask arrays 167 | ================= 168 | 169 | The three main reprojection functions can accept dask arrays as inputs, e.g. 170 | assuming you have already constructed a dask array named ``dask_array``:: 171 | 172 | >>> dask_array 173 | dask.array 174 | 175 | you can pass this in as part of the first argument to one of the reprojection 176 | functions:: 177 | 178 | >>> array, footprint = reproject_interp((dask_array, wcs_in), wcs_out, 179 | ... shape_out=(2048, 2048)) 180 | 181 | In general however, we cannot benefit much from the chunking of the input arrays 182 | because any input pixel might in principle contribute to any output pixel. 183 | Therefore, for now, when a dask array is passed as input, it is computed using 184 | the current default scheduler and converted to a Numpy memory-mapped array. This 185 | is done efficiently in terms of memory and never results in the whole dataset 186 | being loaded into memory at any given time. However, this does require 187 | sufficient space on disk to store the array. If your default system temporary 188 | directory does not have sufficient space, you can set the ``TMPDIR`` environment 189 | variable to point at another directory: 190 | 191 | >>> import os 192 | >>> os.environ['TMPDIR'] = '/home/lancelot/tmp' 193 | 194 | 195 | Output dask arrays 196 | ================== 197 | 198 | By default, the reprojection functions will do the computation immediately and 199 | return Numpy arrays for the reprojected array and optionally the footprint (this 200 | is regardless of whether dask or Numpy arrays were passed in, or any of the 201 | parallelization options above). However, by setting ``return_type='dask'``, you 202 | can make the functions delay any computation and return dask arrays:: 203 | 204 | >>> array, footprint = reproject_interp((input_array, wcs_in), wcs_out, 205 | ... shape_out=(2048, 2048), block_size=(256, 256), 206 | ... return_type='dask') 207 | >>> array 208 | dask.array 209 | 210 | You can then compute the array or a section of the array yourself whenever you need, or use the 211 | result in further dask expressions. 212 | 213 | Using dask.distributed 214 | ====================== 215 | 216 | The `dask.distributed `_ package makes it 217 | possible to use distributed schedulers for dask. In order to compute 218 | reprojections with dask.distributed, set up the client and then call the reprojection 219 | functions with ``parallel='current-scheduler'``. Alternatively, you can make use of the 220 | ``return_type='dask'`` option mentioned above so that you can compute the dask 221 | array once the distributed scheduler has been set up. 222 | -------------------------------------------------------------------------------- /docs/performance_mosaicking.rst: -------------------------------------------------------------------------------- 1 | ************************************* 2 | Optimizing performance for mosaicking 3 | ************************************* 4 | 5 | Using memory-mapped output arrays 6 | ================================= 7 | 8 | If you are producing a large mosaic, you may be want to write the mosaic and 9 | footprint to an array of your choice, such as for example a memory-mapped array. 10 | For example: 11 | 12 | .. doctest-skip:: 13 | 14 | >>> output_array = np.memmap(filename='array.np', mode='w+', 15 | ... shape=shape_out, dtype='float32') 16 | >>> output_footprint = np.memmap(filename='footprint.np', mode='w+', 17 | ... shape=shape_out, dtype='float32') 18 | >>> reproject_and_coadd(..., 19 | ... output_array=output_array, 20 | ... output_footprint=output_footprint) 21 | 22 | Using memory-mapped intermediate arrays 23 | ======================================= 24 | 25 | During the mosaicking process, each cube is reprojected to the minimal subset of 26 | the final header that it covers. In some cases, this can result in arrays that 27 | may not fit in memory. In this case, you can use the ``intermediate_memmap`` 28 | option to indicate that all intermediate arrays in the mosaicking process should 29 | use memory-mapped arrays rather than in-memory arrays: 30 | 31 | .. doctest-skip:: 32 | 33 | >>> reproject_and_coadd(..., 34 | ... intermediate_memmap=True) 35 | 36 | Combined with the above option to specify the output array and footprint for the 37 | final mosaic, it is possible to make sure that no large arrays are ever loaded 38 | into memory. Note however that you will need to make sure you have sufficient disk 39 | space in your temporary directory. If your default system temporary directory does 40 | not have sufficient space, you can set the ``TMPDIR`` environment variable to point 41 | at another directory: 42 | 43 | >>> import os 44 | >>> os.environ['TMPDIR'] = '/home/lancelot/tmp' 45 | 46 | Multi-threading 47 | =============== 48 | 49 | Similarly to single-image reprojection (see :ref:`multithreading`), it is possible 50 | to make use of multi-threading during the mosaicking process by setting the 51 | ``parallel=`` option to True or to an integer value to indicate the number of 52 | threads to use. 53 | 54 | Using dask.distributed 55 | ====================== 56 | 57 | The `dask.distributed `_ package makes 58 | it possible to use distributed schedulers for dask. In order to do mosaicking 59 | with dask.distributed, set up the client and then call 60 | :func:`~reproject.mosaicking.reproject_and_coadd` with the 61 | ``parallel='current-scheduler'`` option. 62 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "reproject" 3 | authors = [ 4 | {name = "Thomas Robitaille", email = "thomas.robitaille@gmail.com"}, 5 | {name = "Christoph Deil"}, 6 | {name = "Adam Ginsburg"}, 7 | ] 8 | license = {text = "BSD 3-Clause"} 9 | description = "Reproject astronomical images" 10 | urls = {Homepage = "https://reproject.readthedocs.io"} 11 | requires-python = ">=3.11" 12 | dependencies = [ 13 | "numpy>=1.23", 14 | "astropy>=5.0", 15 | "astropy-healpix>=1.0", 16 | "scipy>=1.9", 17 | "dask[array]>=2021.8", 18 | "cloudpickle", 19 | "zarr", 20 | "fsspec", 21 | ] 22 | dynamic = ["version"] 23 | 24 | [project.readme] 25 | file = "README.rst" 26 | content-type = "text/x-rst" 27 | 28 | [project.optional-dependencies] 29 | all = ["shapely"] 30 | test = [ 31 | "pytest-astropy", 32 | ] 33 | testall = [ 34 | "shapely>=2.0.2", # 2.0.2 fixed a bug that causes changes in test results 35 | "sunpy[map]>=6.0.1", 36 | "asdf", 37 | "gwcs", 38 | "pyvo", 39 | ] 40 | docs = [ 41 | "sphinx-astropy", 42 | "pyvo", 43 | "matplotlib", 44 | ] 45 | 46 | [build-system] 47 | requires = ["setuptools", 48 | "setuptools_scm", 49 | "extension-helpers>=1.3,<2", 50 | "numpy>=2", 51 | "cython>=3.1"] 52 | build-backend = 'setuptools.build_meta' 53 | 54 | [tool.setuptools] 55 | zip-safe = false 56 | license-files = ["LICENSE"] 57 | include-package-data = false 58 | 59 | [tool.setuptools.packages] 60 | find = {namespaces = false} 61 | 62 | [tool.setuptools.package-data] 63 | "reproject.healpix.tests" = ["data/*"] 64 | "reproject.adaptive.tests" = ["reference/*"] 65 | "reproject.interpolation.tests" = ["reference/*"] 66 | "reproject.mosaicking.tests" = ["reference/*"] 67 | "reproject.spherical_intersect" = ["overlapArea.h", "reproject_slice_c.h", "mNaN.h"] 68 | "reproject.tests" = ["data/*"] 69 | 70 | [tool.extension-helpers] 71 | use_extension_helpers = "true" 72 | 73 | [tool.pytest.ini_options] 74 | minversion = "6" 75 | log_cli_level = "INFO" 76 | xfail_strict = true 77 | testpaths = ['"reproject"', '"docs"'] 78 | norecursedirs = ["build", "docs/_build"] 79 | astropy_header = true 80 | doctest_plus = "enabled" 81 | text_file_format = "rst" 82 | addopts = ["-ra", "--strict-config", "--strict-markers", "--doctest-rst", "--arraydiff", "--arraydiff-default-format=fits", "--doctest-ignore-import-errors"] 83 | filterwarnings = [ 84 | "error", 85 | 'ignore:numpy\.ufunc size changed:RuntimeWarning', 86 | 'ignore:numpy\.ndarray size changed:RuntimeWarning', 87 | "ignore:distutils Version classes are deprecated:DeprecationWarning", 88 | "ignore:No observer defined on WCS:astropy.utils.exceptions.AstropyUserWarning", 89 | "ignore:unclosed file:ResourceWarning", 90 | "ignore:The conversion of these 2D helioprojective coordinates to 3D is all NaNs", 91 | # This is a sunpy < 4.1 issue with Python 3.11 92 | "ignore:'xdrlib' is deprecated and slated for removal in Python 3.13", 93 | # This is a pyvo issue with Python 3.11 94 | "ignore:'cgi' is deprecated and slated for removal in Python 3.13", 95 | # Issue with zarr and dask mismatch 96 | "ignore:ignoring keyword argument 'read_only'" 97 | 98 | ] 99 | 100 | [tool.coverage.run] 101 | omit = [ 102 | "reproject/_astropy_init*", 103 | "reproject/conftest.py", 104 | "reproject/*setup_package*", 105 | "reproject/tests/*", 106 | "reproject/*/tests/*", 107 | "reproject/extern/*", 108 | "reproject/version*", 109 | "*/reproject/_astropy_init*", 110 | "*/reproject/conftest.py", 111 | "*/reproject/*setup_package*", 112 | "*/reproject/tests/*", 113 | "*/reproject/*/tests/*", 114 | "*/reproject/extern/*", 115 | "*/reproject/version*", 116 | ] 117 | 118 | [tool.coverage.report] 119 | exclude_lines = [ 120 | # Have to re-enable the standard pragma 121 | "pragma: no cover", 122 | # Don't complain about packages we have installed 123 | "except ImportError", 124 | # Don't complain if tests don't hit assertions 125 | "raise AssertionError", 126 | "raise NotImplementedError", 127 | # Don't complain about script hooks 128 | 'def main\(.*\):', 129 | # Ignore branches that don't pertain to this version of Python 130 | "pragma: py{ignore_python_version}", 131 | # Don't complain about IPython completion helper 132 | "def _ipython_key_completions_", 133 | ] 134 | 135 | [tool.flake8] 136 | max-line-length = "100" 137 | 138 | [tool.setuptools_scm] 139 | write_to = "reproject/version.py" 140 | 141 | [tool.cibuildwheel] 142 | skip = "cp36-* pp* *-musllinux*" 143 | test-skip = "*-manylinux_aarch64" 144 | 145 | [tool.isort] 146 | profile = "black" 147 | multi_line_output = 3 148 | extend_skip_glob = [ 149 | "docs/*", 150 | "setup.py"] 151 | line_length = 100 152 | known_third_party = ["astropy"] 153 | known_first_party = ["reproject"] 154 | group_by_package = true 155 | indented_import_headings = false 156 | length_sort_sections = ["future", "stdlib"] 157 | 158 | [tool.black] 159 | line-length = 100 160 | target-version = ['py38'] 161 | 162 | [tool.numpydoc_validation] 163 | checks = [ 164 | "all", # report on all checks, except the below 165 | "EX01", 166 | "SA01", 167 | "SS06", 168 | "ES01", 169 | "GL08", 170 | ] 171 | 172 | [tool.repo-review] 173 | ignore = [ 174 | "MY", # ignore MyPy setting checks 175 | "PC111", # ignore using `blacken-docs` in pre-commit 176 | "PC140", # ignore using `mypy` in pre-commit 177 | "PC180", # ignore using `prettier` in pre-commit 178 | "PC901", # ignore using custom update message (we have many of the default ones in our history already) 179 | "PC170", # ignore using pygrep 180 | "PY005", # ignore having a tests/ folder 181 | ] 182 | 183 | [tool.ruff] 184 | lint.select = [ 185 | "B", # flake8-bugbear 186 | "I", # isort 187 | "UP", # pyupgrade 188 | ] 189 | 190 | [tool.ruff.lint.extend-per-file-ignores] 191 | "docs/conf.py" = ["F405"] # Sphinx injects variables into namespace 192 | 193 | [tool.distutils.bdist_wheel] 194 | py-limited-api = "cp311" 195 | -------------------------------------------------------------------------------- /reproject/__init__.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | """ 3 | Astropy affiliated package for image reprojection (resampling). 4 | """ 5 | from .adaptive import reproject_adaptive # noqa 6 | from .healpix import reproject_from_healpix, reproject_to_healpix # noqa 7 | from .interpolation import reproject_interp # noqa 8 | from .spherical_intersect import reproject_exact # noqa 9 | from .version import __version__ # noqa 10 | -------------------------------------------------------------------------------- /reproject/adaptive/__init__.py: -------------------------------------------------------------------------------- 1 | from .high_level import reproject_adaptive # noqa 2 | -------------------------------------------------------------------------------- /reproject/adaptive/core.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | 3 | import numpy as np 4 | from astropy.wcs.utils import pixel_to_pixel 5 | 6 | from ..wcs_utils import pixel_to_pixel_with_roundtrip 7 | from .deforest import map_coordinates 8 | 9 | __all__ = ["_reproject_adaptive_2d"] 10 | 11 | 12 | class CoordinateTransformer: 13 | def __init__(self, wcs_in, wcs_out, roundtrip_coords): 14 | self.wcs_in = wcs_in 15 | self.wcs_out = wcs_out 16 | self.roundtrip_coords = roundtrip_coords 17 | 18 | def __call__(self, pixel_out): 19 | pixel_out = pixel_out[:, :, 0], pixel_out[:, :, 1] 20 | if self.roundtrip_coords: 21 | pixel_in = pixel_to_pixel_with_roundtrip(self.wcs_out, self.wcs_in, *pixel_out) 22 | else: 23 | pixel_in = pixel_to_pixel(self.wcs_out, self.wcs_in, *pixel_out) 24 | pixel_in = np.array(pixel_in).transpose().swapaxes(0, 1) 25 | return pixel_in 26 | 27 | 28 | def _reproject_adaptive_2d( 29 | array, 30 | wcs_in, 31 | wcs_out, 32 | shape_out, 33 | array_out=None, 34 | output_footprint=None, 35 | return_footprint=True, 36 | center_jacobian=False, 37 | despike_jacobian=False, 38 | roundtrip_coords=True, 39 | conserve_flux=False, 40 | kernel="Gaussian", 41 | kernel_width=1.3, 42 | sample_region_width=4, 43 | boundary_mode="strict", 44 | boundary_fill_value=0, 45 | boundary_ignore_threshold=0.5, 46 | x_cyclic=False, 47 | y_cyclic=False, 48 | bad_value_mode="strict", 49 | bad_fill_value=0, 50 | ): 51 | """ 52 | Reproject celestial slices from an n-d array from one WCS to another 53 | using the DeForest (2004) algorithm [1]_, and assuming all other dimensions 54 | are independent. 55 | 56 | Parameters 57 | ---------- 58 | array : `~numpy.ndarray` 59 | The array to reproject 60 | wcs_in : `~astropy.wcs.WCS` 61 | The input WCS 62 | wcs_out : `~astropy.wcs.WCS` 63 | The output WCS 64 | shape_out : tuple 65 | The shape of the output array 66 | return_footprint : bool 67 | Whether to return the footprint in addition to the output array. 68 | center_jacobian : bool 69 | Whether to compute centered Jacobians 70 | despike_jacobian : bool 71 | Whether to despike the Jacobians 72 | roundtrip_coords : bool 73 | Whether to veryfiy that coordinate transformations are defined in both 74 | directions. 75 | conserve_flux : bool 76 | Whether to rescale output pixel values so flux is conserved 77 | kernel : str 78 | The averaging kernel to use. 79 | kernel_width : double 80 | The width of the kernel in pixels. Applies only to the Gaussian kernel. 81 | sample_region_width : double 82 | The width in pixels of the sample region, used only for the Gaussian 83 | kernel which otherwise has infinite extent. 84 | boundary_mode : str 85 | Boundary handling mode 86 | boundary_fill_value : double 87 | Fill value for 'constant' boundary mode 88 | boundary_ignore_threshold : double 89 | Threshold for 'ignore_threshold' boundary mode, ranging from 0 to 1. 90 | x_cyclic, y_cyclic : bool 91 | Marks in input-image axis as cyclic. 92 | bad_value_mode : str 93 | NaN and inf handling mode 94 | bad_fill_value : float 95 | Fill value for 'constant' bad value mode 96 | 97 | Returns 98 | ------- 99 | array_new : `~numpy.ndarray` 100 | The reprojected array 101 | footprint : `~numpy.ndarray` 102 | Footprint of the input array in the output array. Values of 0 indicate 103 | no coverage or valid values in the input image, while values of 1 104 | indicate valid values. 105 | 106 | References 107 | ---------- 108 | .. [1] C. E. DeForest, "On Re-sampling of Solar Images" 109 | Solar Physics volume 219, pages 3–23 (2004), 110 | https://doi.org/10.1023/B:SOLA.0000021743.24248.b0 111 | 112 | Note 113 | ---- 114 | If the input array contains extra dimensions beyond what the input WCS has, 115 | the extra leading dimensions are assumed to represent multiple images with 116 | the same coordinate information. The transformation is computed once and 117 | "broadcast" across those images. 118 | """ 119 | 120 | # Make sure image is floating point 121 | array_in = np.asarray(array, dtype=float) 122 | shape_out = tuple(shape_out) 123 | 124 | # Check dimensionality of WCS and shape_out 125 | if wcs_in.low_level_wcs.pixel_n_dim != wcs_out.low_level_wcs.pixel_n_dim: 126 | raise ValueError("Number of dimensions between input and output WCS should match") 127 | elif len(shape_out) < wcs_out.low_level_wcs.pixel_n_dim: 128 | raise ValueError("Too few dimensions in shape_out") 129 | elif len(array_in.shape) < wcs_in.low_level_wcs.pixel_n_dim: 130 | raise ValueError("Too few dimensions in input data") 131 | elif len(array_in.shape) != len(shape_out): 132 | raise ValueError("Number of dimensions in input and output data should match") 133 | 134 | # Separate the "extra" dimensions that don't correspond to a WCS axis and 135 | # which we'll be looping over 136 | extra_dimens_in = array_in.shape[: -wcs_in.low_level_wcs.pixel_n_dim] 137 | extra_dimens_out = shape_out[: -wcs_out.low_level_wcs.pixel_n_dim] 138 | if extra_dimens_in != extra_dimens_out: 139 | raise ValueError("Dimensions to be looped over must match exactly") 140 | 141 | if array_out is None: 142 | array_out = np.empty(shape_out) 143 | 144 | if output_footprint is None: 145 | output_footprint = np.empty(shape_out) 146 | 147 | if len(array_in.shape) == wcs_in.low_level_wcs.pixel_n_dim: 148 | # We don't need to broadcast the transformation over any extra 149 | # axes---add an extra axis of length one just so we have something 150 | # to loop over in all cases. 151 | array_in = array_in.reshape((1, *array_in.shape)) 152 | array_out = array_out.reshape((1, *array_out.shape)) 153 | elif len(array_in.shape) > wcs_in.low_level_wcs.pixel_n_dim: 154 | # We're broadcasting. Flatten the extra dimensions so there's just one 155 | # to loop over 156 | array_in = array_in.reshape((-1, *array_in.shape[-wcs_in.low_level_wcs.pixel_n_dim :])) 157 | array_out = array_out.reshape((-1, *array_out.shape[-wcs_out.low_level_wcs.pixel_n_dim :])) 158 | 159 | transformer = CoordinateTransformer(wcs_in, wcs_out, roundtrip_coords) 160 | map_coordinates( 161 | array_in, 162 | array_out, 163 | transformer, 164 | out_of_range_nan=True, 165 | center_jacobian=center_jacobian, 166 | despiked_jacobian=despike_jacobian, 167 | conserve_flux=conserve_flux, 168 | kernel=kernel, 169 | kernel_width=kernel_width, 170 | sample_region_width=sample_region_width, 171 | boundary_mode=boundary_mode, 172 | boundary_fill_value=boundary_fill_value, 173 | boundary_ignore_threshold=boundary_ignore_threshold, 174 | x_cyclic=x_cyclic, 175 | y_cyclic=y_cyclic, 176 | bad_value_mode=bad_value_mode, 177 | bad_fill_value=bad_fill_value, 178 | ) 179 | 180 | array_out.shape = shape_out 181 | 182 | if return_footprint: 183 | output_footprint[:] = (~np.isnan(array_out)).astype(float) 184 | return array_out, output_footprint 185 | else: 186 | return array_out 187 | -------------------------------------------------------------------------------- /reproject/adaptive/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/adaptive/tests/__init__.py -------------------------------------------------------------------------------- /reproject/adaptive/tests/reference/test_reproject_adaptive_2d.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/adaptive/tests/reference/test_reproject_adaptive_2d.fits -------------------------------------------------------------------------------- /reproject/adaptive/tests/reference/test_reproject_adaptive_2d_rotated.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/adaptive/tests/reference/test_reproject_adaptive_2d_rotated.fits -------------------------------------------------------------------------------- /reproject/adaptive/tests/reference/test_reproject_adaptive_roundtrip.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/adaptive/tests/reference/test_reproject_adaptive_roundtrip.fits -------------------------------------------------------------------------------- /reproject/adaptive/tests/reference/test_reproject_adaptive_uncentered_jacobian.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/adaptive/tests/reference/test_reproject_adaptive_uncentered_jacobian.fits -------------------------------------------------------------------------------- /reproject/array_utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | __all__ = ["map_coordinates", "sample_array_edges"] 4 | 5 | 6 | def find_chunk_shape(shape, max_chunk_size=None): 7 | """ 8 | Given the shape of an n-dimensional array, and the maximum number of 9 | elements in a chunk, return the largest chunk shape to use for iteration. 10 | 11 | This currently assumes the optimal chunk shape to return is for C-contiguous 12 | arrays. 13 | 14 | Parameters 15 | ---------- 16 | shape : iterable 17 | The shape of the n-dimensional array. 18 | max_chunk_size : int, optional 19 | The maximum number of elements per chunk. 20 | """ 21 | 22 | if max_chunk_size is None: 23 | return tuple(shape) 24 | 25 | block_shape = [] 26 | 27 | max_repeat_remaining = max_chunk_size 28 | 29 | for size in shape[::-1]: 30 | if max_repeat_remaining > size: 31 | block_shape.append(size) 32 | max_repeat_remaining = max_repeat_remaining // size 33 | else: 34 | block_shape.append(max_repeat_remaining) 35 | max_repeat_remaining = 1 36 | 37 | return tuple(block_shape[::-1]) 38 | 39 | 40 | def iterate_chunks(shape, *, max_chunk_size): 41 | """ 42 | Given a data shape and a chunk shape (or maximum chunk size), iteratively 43 | return slice objects that can be used to slice the array. 44 | 45 | Parameters 46 | ---------- 47 | shape : iterable 48 | The shape of the n-dimensional array. 49 | max_chunk_size : int 50 | The maximum number of elements per chunk. 51 | """ 52 | 53 | if np.prod(shape) == 0: 54 | return 55 | 56 | chunk_shape = find_chunk_shape(shape, max_chunk_size) 57 | 58 | ndim = len(chunk_shape) 59 | start_index = [0] * ndim 60 | 61 | shape = list(shape) 62 | 63 | while start_index <= shape: 64 | end_index = [min(start_index[i] + chunk_shape[i], shape[i]) for i in range(ndim)] 65 | 66 | slices = tuple([slice(start_index[i], end_index[i]) for i in range(ndim)]) 67 | 68 | yield slices 69 | 70 | # Update chunk index. What we do is to increment the 71 | # counter for the first dimension, and then if it 72 | # exceeds the number of elements in that direction, 73 | # cycle back to zero and advance in the next dimension, 74 | # and so on. 75 | start_index[0] += chunk_shape[0] 76 | for i in range(ndim - 1): 77 | if start_index[i] >= shape[i]: 78 | start_index[i] = 0 79 | start_index[i + 1] += chunk_shape[i + 1] 80 | 81 | # We can now check whether the iteration is finished 82 | if start_index[-1] >= shape[-1]: 83 | break 84 | 85 | 86 | def at_least_float32(array): 87 | if array.dtype.kind == "f" and array.dtype.itemsize >= 4: 88 | return array 89 | else: 90 | return array.astype(np.float32) 91 | 92 | 93 | def memory_efficient_access(array, chunk): 94 | # If we access a number of chunks from a memory-mapped array, memory usage 95 | # will increase and could crash e.g. dask.distributed workers. We therefore 96 | # use a temporary memmap to load the data. 97 | if isinstance(array, np.memmap) and array.flags.c_contiguous: 98 | array_tmp = np.memmap( 99 | array.filename, 100 | mode="r", 101 | dtype=array.dtype, 102 | shape=array.shape, 103 | offset=array.offset, 104 | ) 105 | return array_tmp[chunk] 106 | else: 107 | return array[chunk] 108 | 109 | 110 | def map_coordinates( 111 | image, coords, max_chunk_size=None, output=None, optimize_memory=False, **kwargs 112 | ): 113 | # In the built-in scipy map_coordinates, the values are defined at the 114 | # center of the pixels. This means that map_coordinates does not 115 | # correctly treat pixels that are in the outer half of the outer pixels. 116 | # We solve this by resetting any coordinates that are in the outer half of 117 | # the border pixels to be at the center of the border pixels. We used to 118 | # instead pad the array but this was not memory efficient as it ended up 119 | # producing a copy of the output array. 120 | 121 | # In addition, map_coordinates is not efficient when given big-endian Numpy 122 | # arrays as it will then make a copy, which is an issue when dealing with 123 | # memory-mapped FITS files that might be larger than memory. Therefore, for 124 | # big-endian arrays, we operate in chunks with a size smaller or equal to 125 | # max_chunk_size. 126 | 127 | # The optimize_memory option isn't used right not by the rest of reproject 128 | # but it is a mode where if we are in a memory-constrained environment, we 129 | # re-create memmaps for individual chunks to avoid caching the whole array. 130 | # We need to decide how to expose this to users. 131 | 132 | # TODO: check how this should behave on a big-endian system. 133 | 134 | from scipy.ndimage import map_coordinates as scipy_map_coordinates 135 | 136 | original_shape = image.shape 137 | 138 | # We copy the coordinates array as we then modify it in-place below to clip 139 | # to the edges of the array. 140 | 141 | coords = coords.copy() 142 | for i in range(coords.shape[0]): 143 | coords[i][(coords[i] < 0) & (coords[i] >= -0.5)] = 0 144 | coords[i][(coords[i] < original_shape[i] - 0.5) & (coords[i] >= original_shape[i] - 1)] = ( 145 | original_shape[i] - 1 146 | ) 147 | 148 | # If the data type is native and we are not doing spline interpolation, 149 | # then scipy_map_coordinates deals properly with memory maps, so we can use 150 | # it without chunking. Otherwise, we need to iterate over data chunks. 151 | if image.dtype.isnative and "order" in kwargs and kwargs["order"] <= 1: 152 | values = scipy_map_coordinates(at_least_float32(image), coords, output=output, **kwargs) 153 | else: 154 | if output is None: 155 | output = np.repeat(np.nan, coords.shape[1]) 156 | 157 | values = output 158 | 159 | include = np.ones(coords.shape[1], dtype=bool) 160 | 161 | if "order" in kwargs and kwargs["order"] <= 1: 162 | padding = 1 163 | else: 164 | padding = 10 165 | 166 | for chunk in iterate_chunks(image.shape, max_chunk_size=max_chunk_size): 167 | 168 | include[...] = True 169 | for idim, slc in enumerate(chunk): 170 | include[(coords[idim] < slc.start) | (coords[idim] >= slc.stop)] = False 171 | 172 | if not np.any(include): 173 | continue 174 | 175 | chunk = list(chunk) 176 | 177 | # Adjust chunks to add padding 178 | for idim, slc in enumerate(chunk): 179 | start = max(0, slc.start - 10) 180 | stop = min(original_shape[idim], slc.stop + 10) 181 | chunk[idim] = slice(start, stop) 182 | 183 | chunk = tuple(chunk) 184 | 185 | coords_subset = coords[:, include].copy() 186 | for idim, slc in enumerate(chunk): 187 | coords_subset[idim, :] -= slc.start 188 | 189 | if optimize_memory: 190 | image_subset = memory_efficient_access(image, chunk) 191 | else: 192 | image_subset = image[chunk] 193 | 194 | output[include] = scipy_map_coordinates( 195 | at_least_float32(image_subset), coords_subset, **kwargs 196 | ) 197 | 198 | reset = np.zeros(coords.shape[1], dtype=bool) 199 | 200 | for i in range(coords.shape[0]): 201 | reset |= coords[i] < -0.5 202 | reset |= coords[i] > original_shape[i] - 0.5 203 | 204 | values[reset] = kwargs.get("cval", 0.0) 205 | 206 | return values 207 | 208 | 209 | def sample_array_edges(shape, *, n_samples): 210 | # Given an N-dimensional array shape, sample each edge of the array using 211 | # the requested number of samples (which will include vertices). To do this 212 | # we iterate through the dimensions and for each one we sample the points 213 | # in that dimension and iterate over the combination of other vertices. 214 | # Returns an array with dimensions (N, n_samples) 215 | all_positions = [] 216 | ndim = len(shape) 217 | shape = np.array(shape) 218 | for idim in range(ndim): 219 | for vertex in range(2**ndim): 220 | positions = -0.5 + shape * ((vertex & (2 ** np.arange(ndim))) > 0).astype(int) 221 | positions = np.broadcast_to(positions, (n_samples, ndim)).copy() 222 | positions[:, idim] = np.linspace(-0.5, shape[idim] - 0.5, n_samples) 223 | all_positions.append(positions) 224 | positions = np.unique(np.vstack(all_positions), axis=0).T 225 | return positions 226 | -------------------------------------------------------------------------------- /reproject/conftest.py: -------------------------------------------------------------------------------- 1 | # This file is used to configure the behavior of pytest when using the Astropy 2 | # test infrastructure. It needs to live inside the package in order for it to 3 | # get picked up when running the tests inside an interpreter using 4 | # packagename.test 5 | 6 | import os 7 | 8 | import dask.array as da 9 | import numpy as np 10 | import pytest 11 | from astropy import units as u 12 | from astropy.io import fits 13 | from astropy.nddata import NDData 14 | from astropy.wcs import WCS 15 | from astropy.wcs.wcsapi import HighLevelWCSMixin, SlicedLowLevelWCS 16 | 17 | try: 18 | from pytest_astropy_header.display import PYTEST_HEADER_MODULES, TESTED_VERSIONS 19 | 20 | ASTROPY_HEADER = True 21 | except ImportError: 22 | ASTROPY_HEADER = False 23 | 24 | os.environ["MPLBACKEND"] = "Agg" 25 | 26 | 27 | def pytest_configure(config): 28 | if ASTROPY_HEADER: 29 | config.option.astropy_header = True 30 | 31 | PYTEST_HEADER_MODULES.pop("Pandas", None) 32 | PYTEST_HEADER_MODULES.pop("h5py", None) 33 | PYTEST_HEADER_MODULES.pop("Matplotlib", None) 34 | PYTEST_HEADER_MODULES["Astropy"] = "astropy" 35 | PYTEST_HEADER_MODULES["astropy-healpix"] = "astropy_healpix" 36 | PYTEST_HEADER_MODULES["Cython"] = "cython" 37 | 38 | from reproject import __version__ 39 | 40 | TESTED_VERSIONS["reproject"] = __version__ 41 | 42 | 43 | class TestLowLevelWCS(SlicedLowLevelWCS): 44 | # The simplest way to get a 'pure' low level WCS is to call SlicedLowLevelWCS 45 | # with an ellipsis slice! 46 | 47 | def __init__(self, low_level_wcs): 48 | self._low_level_wcs = low_level_wcs 49 | super().__init__(low_level_wcs, Ellipsis) 50 | 51 | 52 | class TestHighLevelWCS(HighLevelWCSMixin): 53 | def __init__(self, low_level_wcs): 54 | self._low_level_wcs = low_level_wcs 55 | 56 | @property 57 | def low_level_wcs(self): 58 | return self._low_level_wcs 59 | 60 | # FIXME: due to a bug in astropy we need world_n_dim to be defined here 61 | 62 | @property 63 | def world_n_dim(self): 64 | return self.low_level_wcs.world_n_dim 65 | 66 | @property 67 | def pixel_n_dim(self): 68 | return self.low_level_wcs.pixel_n_dim 69 | 70 | 71 | @pytest.fixture 72 | def simple_celestial_fits_wcs(): 73 | wcs = WCS(naxis=2) 74 | wcs.wcs.ctype = "RA---TAN", "DEC--TAN" 75 | wcs.wcs.crpix = (1, 2) 76 | wcs.wcs.crval = (30, 40) 77 | wcs.wcs.cdelt = (-0.05, 0.04) 78 | wcs.wcs.equinox = 2000.0 79 | return wcs 80 | 81 | 82 | @pytest.fixture(params=["fits_wcs", "ape14_low_level_wcs", "ape14_high_level_wcs"]) 83 | def simple_celestial_wcs(request, simple_celestial_fits_wcs): 84 | if request.param == "fits_wcs": 85 | return simple_celestial_fits_wcs 86 | elif request.param == "ape14_low_level_wcs": 87 | return TestLowLevelWCS(simple_celestial_fits_wcs) 88 | elif request.param == "ape14_high_level_wcs": 89 | return TestHighLevelWCS(simple_celestial_fits_wcs) 90 | 91 | 92 | def set_wcs_array_shape(wcs, shape): 93 | if isinstance(wcs, WCS): 94 | wcs._naxis = list(shape[::-1]) 95 | elif isinstance(wcs, TestLowLevelWCS): 96 | wcs._low_level_wcs._naxis = list(shape[::-1]) 97 | elif isinstance(wcs, TestHighLevelWCS): 98 | wcs.low_level_wcs._naxis = list(shape[::-1]) 99 | 100 | 101 | def valid_celestial_input(tmp_path, request, wcs): 102 | array = np.ones((30, 40)) 103 | 104 | kwargs = {} 105 | 106 | if "hdu" in request.param or request.param in ["filename", "path"]: 107 | if not isinstance(wcs, WCS): 108 | pytest.skip() 109 | 110 | hdulist = fits.HDUList( 111 | [ 112 | fits.PrimaryHDU(array, wcs.to_header()), 113 | fits.ImageHDU(array, wcs.to_header()), 114 | fits.CompImageHDU(array, wcs.to_header()), 115 | ] 116 | ) 117 | 118 | if request.param in ["filename", "path"]: 119 | input_value = tmp_path / "test.fits" 120 | if request.param == "filename": 121 | input_value = str(input_value) 122 | hdulist.writeto(input_value) 123 | kwargs["hdu_in"] = 0 124 | elif request.param == "hdulist": 125 | input_value = hdulist 126 | kwargs["hdu_in"] = 1 127 | elif request.param == "primary_hdu": 128 | input_value = hdulist[0] 129 | elif request.param == "image_hdu": 130 | input_value = hdulist[1] 131 | elif request.param == "comp_image_hdu": 132 | input_value = hdulist[2] 133 | elif request.param == "shape_wcs_tuple": 134 | input_value = (array.shape, wcs) 135 | elif request.param == "data_wcs_tuple": 136 | input_value = (array, wcs) 137 | elif request.param == "dask_wcs_tuple": 138 | input_value = (da.from_array(array), wcs) 139 | elif request.param == "nddata": 140 | input_value = NDData(data=array, wcs=wcs) 141 | elif request.param == "nddata_dask": 142 | input_value = NDData(data=da.from_array(array), wcs=wcs) 143 | elif request.param == "wcs": 144 | set_wcs_array_shape(wcs, array.shape) 145 | input_value = wcs 146 | elif request.param == "shape_wcs_tuple": 147 | input_value = (array.shape, wcs) 148 | else: 149 | raise ValueError(f"Unknown mode: {request.param}") 150 | 151 | return array, wcs, input_value, kwargs 152 | 153 | 154 | COMMON_PARAMS = [ 155 | "filename", 156 | "path", 157 | "hdulist", 158 | "primary_hdu", 159 | "image_hdu", 160 | "comp_image_hdu", 161 | "data_wcs_tuple", 162 | "dask_wcs_tuple", 163 | "nddata", 164 | "nddata_dask", 165 | ] 166 | 167 | 168 | @pytest.fixture(params=COMMON_PARAMS) 169 | def valid_celestial_input_data(tmp_path, request, simple_celestial_wcs): 170 | return valid_celestial_input(tmp_path, request, simple_celestial_wcs) 171 | 172 | 173 | @pytest.fixture( 174 | params=COMMON_PARAMS 175 | + [ 176 | "wcs", 177 | "shape_wcs_tuple", 178 | ] 179 | ) 180 | def valid_celestial_input_shapes(tmp_path, request, simple_celestial_wcs): 181 | return valid_celestial_input(tmp_path, request, simple_celestial_wcs) 182 | 183 | 184 | @pytest.fixture( 185 | params=[ 186 | "wcs_shape", 187 | "header", 188 | "header_shape", 189 | "wcs", 190 | ] 191 | ) 192 | def valid_celestial_output_projections(request, simple_celestial_wcs): 193 | shape = (30, 40) 194 | wcs = simple_celestial_wcs 195 | 196 | # Rotate the WCS in case this is used for actual reprojection tests 197 | 198 | # wcs.wcs.pc = np.array([[np.cos(0.4), -np.sin(0.4)], [np.sin(0.4), np.cos(0.4)]]) 199 | 200 | kwargs = {} 201 | 202 | if request.param == "wcs_shape": 203 | output_value = wcs 204 | kwargs["shape_out"] = shape 205 | elif request.param == "header": 206 | if not isinstance(wcs, WCS): 207 | pytest.skip() 208 | header = wcs.to_header() 209 | header["NAXIS"] = 2 210 | header["NAXIS1"] = 40 211 | header["NAXIS2"] = 30 212 | output_value = header 213 | elif request.param == "header_shape": 214 | if not isinstance(wcs, WCS): 215 | pytest.skip() 216 | output_value = wcs.to_header() 217 | kwargs["shape_out"] = shape 218 | elif request.param == "wcs": 219 | set_wcs_array_shape(wcs, (30, 40)) 220 | output_value = wcs 221 | 222 | return wcs, shape, output_value, kwargs 223 | 224 | 225 | DATA = os.path.join(os.path.dirname(__file__), "tests", "data") 226 | 227 | 228 | @pytest.fixture(params=["fits", "asdf"]) 229 | def aia_test_data(request): 230 | 231 | pytest.importorskip("sunpy", minversion="6.0.1") 232 | 233 | from sunpy.coordinates.ephemeris import get_body_heliographic_stonyhurst 234 | from sunpy.map import Map 235 | 236 | if request.param == "fits": 237 | map_aia = Map(os.path.join(DATA, "aia_171_level1.fits")) 238 | data = map_aia.data 239 | wcs = map_aia.wcs 240 | date = map_aia.reference_date 241 | target_wcs = wcs.deepcopy() 242 | elif request.param == "asdf": 243 | pytest.importorskip("astropy", minversion="4.0") 244 | pytest.importorskip("gwcs", minversion="0.12") 245 | asdf = pytest.importorskip("asdf") 246 | aia = asdf.open(os.path.join(DATA, "aia_171_level1.asdf")) 247 | data = aia["data"][...] 248 | wcs = aia["wcs"] 249 | date = wcs.output_frame.reference_frame.obstime 250 | target_wcs = Map(os.path.join(DATA, "aia_171_level1.fits")).wcs.deepcopy() 251 | else: 252 | raise ValueError("file_format should be fits or asdf") 253 | 254 | # Reproject to an observer on Venus 255 | 256 | target_wcs.wcs.cdelt = ([24, 24] * u.arcsec).to(u.deg) 257 | target_wcs.wcs.crpix = [64, 64] 258 | venus = get_body_heliographic_stonyhurst("venus", date) 259 | target_wcs.wcs.aux.hgln_obs = venus.lon.to_value(u.deg) 260 | target_wcs.wcs.aux.hglt_obs = venus.lat.to_value(u.deg) 261 | target_wcs.wcs.aux.dsun_obs = venus.radius.to_value(u.m) 262 | 263 | return data, wcs, target_wcs 264 | -------------------------------------------------------------------------------- /reproject/healpix/__init__.py: -------------------------------------------------------------------------------- 1 | from .high_level import * # noqa 2 | -------------------------------------------------------------------------------- /reproject/healpix/core.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from astropy.coordinates import SkyCoord 3 | from astropy_healpix import HEALPix, npix_to_nside 4 | 5 | from reproject.array_utils import map_coordinates 6 | 7 | __all__ = ["healpix_to_image", "image_to_healpix"] 8 | 9 | ORDER = {} 10 | ORDER["nearest-neighbor"] = 0 11 | ORDER["bilinear"] = 1 12 | ORDER["biquadratic"] = 2 13 | ORDER["bicubic"] = 3 14 | 15 | 16 | def healpix_to_image( 17 | healpix_data, coord_system_in, wcs_out, shape_out, order="bilinear", nested=False 18 | ): 19 | """ 20 | Convert image in HEALPIX format to a normal FITS projection image (e.g. 21 | CAR or AIT). 22 | 23 | Parameters 24 | ---------- 25 | healpix_data : `numpy.ndarray` 26 | HEALPIX data array 27 | coord_system_in : str or `~astropy.coordinates.BaseCoordinateFrame` 28 | The coordinate system for the input HEALPIX data, as an Astropy 29 | coordinate frame or corresponding string alias (e.g. ``'icrs'`` or 30 | ``'galactic'``) 31 | wcs_out : `~astropy.wcs.WCS` 32 | The WCS of the output array 33 | shape_out : tuple 34 | The shape of the output array 35 | order : int or str, optional 36 | The order of the interpolation (if ``mode`` is set to 37 | ``'interpolation'``). This can be either one of the following strings: 38 | 39 | * 'nearest-neighbor' 40 | * 'bilinear' 41 | 42 | or an integer. A value of ``0`` indicates nearest neighbor 43 | interpolation. 44 | nested : bool 45 | The order of the healpix_data, either nested or ring. Stored in 46 | FITS headers in the ORDERING keyword. 47 | 48 | Returns 49 | ------- 50 | reprojected_data : `numpy.ndarray` 51 | HEALPIX image resampled onto the reference image 52 | footprint : `~numpy.ndarray` 53 | Footprint of the input array in the output array. Values of 0 indicate 54 | no coverage or valid values in the input image, while values of 1 55 | indicate valid values. 56 | """ 57 | 58 | healpix_data = np.asarray(healpix_data, dtype=float) 59 | 60 | # Look up lon, lat of pixels in reference system and convert celestial coordinates 61 | yinds, xinds = np.indices(shape_out) 62 | world_in = wcs_out.pixel_to_world(xinds, yinds).transform_to(coord_system_in) 63 | world_in_unitsph = world_in.represent_as("unitspherical") 64 | lon_in, lat_in = world_in_unitsph.lon, world_in_unitsph.lat 65 | 66 | if isinstance(order, str): 67 | order = ORDER[order] 68 | 69 | nside = npix_to_nside(len(healpix_data)) 70 | 71 | hp = HEALPix(nside=nside, order="nested" if nested else "ring") 72 | 73 | if order == 1: 74 | with np.errstate(invalid="ignore"): 75 | data = hp.interpolate_bilinear_lonlat(lon_in, lat_in, healpix_data) 76 | footprint = (~np.isnan(data)).astype(float) 77 | elif order == 0: 78 | with np.errstate(invalid="ignore"): 79 | ipix = hp.lonlat_to_healpix(lon_in, lat_in) 80 | data = healpix_data[ipix] 81 | footprint = (ipix != -1).astype(float) 82 | else: 83 | raise ValueError("Only nearest-neighbor and bilinear interpolation are supported") 84 | 85 | return data, footprint 86 | 87 | 88 | def image_to_healpix(data, wcs_in, coord_system_out, nside, order="bilinear", nested=False): 89 | """ 90 | Convert image in a normal WCS projection to HEALPIX format. 91 | 92 | Parameters 93 | ---------- 94 | data : `numpy.ndarray` 95 | Input data array to reproject 96 | wcs_in : `~astropy.wcs.WCS` 97 | The WCS of the input array 98 | coord_system_out : str or `~astropy.coordinates.BaseCoordinateFrame` 99 | The target coordinate system for the HEALPIX projection, as an Astropy 100 | coordinate frame or corresponding string alias (e.g. ``'icrs'`` or 101 | ``'galactic'``) 102 | order : int or str, optional 103 | The order of the interpolation (if ``mode`` is set to 104 | ``'interpolation'``). This can be either one of the following strings: 105 | 106 | * 'nearest-neighbor' 107 | * 'bilinear' 108 | * 'biquadratic' 109 | * 'bicubic' 110 | 111 | or an integer. A value of ``0`` indicates nearest neighbor 112 | interpolation. 113 | nested : bool 114 | The order of the healpix_data, either nested or ring. Stored in 115 | FITS headers in the ORDERING keyword. 116 | 117 | Returns 118 | ------- 119 | reprojected_data : `numpy.ndarray` 120 | A HEALPIX array of values 121 | footprint : `~numpy.ndarray` 122 | Footprint of the input array in the output array. Values of 0 indicate 123 | no coverage or valid values in the input image, while values of 1 124 | indicate valid values. 125 | """ 126 | 127 | hp = HEALPix(nside=nside, order="nested" if nested else "ring") 128 | 129 | npix = hp.npix 130 | 131 | # Look up lon, lat of pixels in output system and convert colatitude theta 132 | # and longitude phi to longitude and latitude. 133 | lon_out, lat_out = hp.healpix_to_lonlat(np.arange(npix)) 134 | 135 | world_out = SkyCoord(lon_out, lat_out, frame=coord_system_out) 136 | 137 | # Look up pixels in input WCS 138 | xinds, yinds = wcs_in.world_to_pixel(world_out) 139 | 140 | # Interpolate 141 | 142 | if isinstance(order, str): 143 | order = ORDER[order] 144 | 145 | healpix_data = map_coordinates( 146 | data, 147 | np.array([yinds, xinds]), 148 | order=order, 149 | mode="constant", 150 | cval=np.nan, 151 | ) 152 | 153 | return healpix_data, (~np.isnan(healpix_data)).astype(float) 154 | -------------------------------------------------------------------------------- /reproject/healpix/high_level.py: -------------------------------------------------------------------------------- 1 | from ..utils import parse_input_data, parse_output_projection 2 | from ..wcs_utils import has_celestial 3 | from .core import healpix_to_image, image_to_healpix 4 | from .utils import parse_coord_system, parse_input_healpix_data 5 | 6 | __all__ = ["reproject_from_healpix", "reproject_to_healpix"] 7 | 8 | 9 | def reproject_from_healpix( 10 | input_data, output_projection, shape_out=None, hdu_in=1, order="bilinear", nested=None, field=0 11 | ): 12 | """ 13 | Reproject data from a HEALPIX projection to a standard projection. 14 | 15 | Parameters 16 | ---------- 17 | input_data : object 18 | The input data to reproject. This can be: 19 | 20 | * The name of a HEALPIX FITS file 21 | * A `~astropy.io.fits.TableHDU` or `~astropy.io.fits.BinTableHDU` 22 | instance 23 | * A tuple where the first element is a `~numpy.ndarray` and the 24 | second element is a `~astropy.coordinates.BaseCoordinateFrame` 25 | instance or a string alias for a coordinate frame. 26 | 27 | output_projection : `~astropy.wcs.wcsapi.BaseLowLevelWCS` or `~astropy.wcs.wcsapi.BaseHighLevelWCS` or `~astropy.io.fits.Header` 28 | The output projection, which can be either a 29 | `~astropy.wcs.wcsapi.BaseLowLevelWCS`, 30 | `~astropy.wcs.wcsapi.BaseHighLevelWCS`, or a `~astropy.io.fits.Header` 31 | instance. 32 | shape_out : tuple, optional 33 | If ``output_projection`` is a WCS instance, the shape of the output 34 | data should be specified separately. 35 | hdu_in : int or str, optional 36 | If ``input_data`` is a FITS file, specifies the HDU to use. 37 | (the default HDU for HEALPIX data is 1, unlike with image files where 38 | it is generally 0). 39 | order : int or str, optional 40 | The order of the interpolation (if ``mode`` is set to 41 | ``'interpolation'``). This can be either one of the following strings: 42 | 43 | * 'nearest-neighbor' 44 | * 'bilinear' 45 | 46 | or an integer. A value of ``0`` indicates nearest neighbor 47 | interpolation. 48 | nested : bool, optional 49 | The order of the healpix_data, either nested (True) or ring (False). 50 | If a FITS file is passed in, this is determined from the header. 51 | field : int, optional 52 | The column to read from the HEALPIX FITS file. If the fits file is a 53 | partial-sky file, field=0 corresponds to the first column after the 54 | pixel index column. 55 | 56 | Returns 57 | ------- 58 | array_new : `~numpy.ndarray` 59 | The reprojected array. 60 | footprint : `~numpy.ndarray` 61 | Footprint of the input array in the output array. Values of 0 indicate 62 | no coverage or valid values in the input image, while values of 1 63 | indicate valid values. 64 | """ 65 | 66 | array_in, coord_system_in, nested = parse_input_healpix_data( 67 | input_data, hdu_in=hdu_in, field=field, nested=nested 68 | ) 69 | wcs_out, shape_out = parse_output_projection(output_projection, shape_out=shape_out) 70 | 71 | if nested is None: 72 | raise ValueError( 73 | "Could not determine whether the data follows the " 74 | "'ring' or 'nested' ordering, so you should set " 75 | "nested=True or nested=False explicitly." 76 | ) 77 | 78 | return healpix_to_image( 79 | array_in, coord_system_in, wcs_out, shape_out, order=order, nested=nested 80 | ) 81 | 82 | 83 | def reproject_to_healpix( 84 | input_data, coord_system_out, hdu_in=0, order="bilinear", nested=False, nside=128 85 | ): 86 | """ 87 | Reproject data from a standard projection to a HEALPIX projection. 88 | 89 | Parameters 90 | ---------- 91 | input_data : object 92 | The input data to reproject. This can be: 93 | 94 | * The name of a FITS file as a `str` or a `pathlib.Path` object 95 | * An `~astropy.io.fits.HDUList` object 96 | * An image HDU object such as a `~astropy.io.fits.PrimaryHDU`, 97 | `~astropy.io.fits.ImageHDU`, or `~astropy.io.fits.CompImageHDU` 98 | instance 99 | * A tuple where the first element is a `~numpy.ndarray` and the 100 | second element is either a 101 | `~astropy.wcs.wcsapi.BaseLowLevelWCS`, 102 | `~astropy.wcs.wcsapi.BaseHighLevelWCS`, or a 103 | `~astropy.io.fits.Header` object 104 | * An `~astropy.nddata.NDData` object from which the ``.data`` and 105 | ``.wcs`` attributes will be used as the input data. 106 | 107 | coord_system_out : `~astropy.coordinates.BaseCoordinateFrame` or str 108 | The output coordinate system for the HEALPIX projection. 109 | hdu_in : int or str, optional 110 | If ``input_data`` is a FITS file or an `~astropy.io.fits.HDUList` 111 | instance, specifies the HDU to use. 112 | order : int or str, optional 113 | The order of the interpolation (if ``mode`` is set to 114 | ``'interpolation'``). This can be either one of the following strings: 115 | 116 | * 'nearest-neighbor' 117 | * 'bilinear' 118 | * 'biquadratic' 119 | * 'bicubic' 120 | 121 | or an integer. A value of ``0`` indicates nearest neighbor 122 | interpolation. 123 | nested : bool 124 | The order of the healpix_data, either nested (`True`) or ring (`False`). 125 | nside : int, optional 126 | The resolution of the HEALPIX projection. 127 | 128 | Returns 129 | ------- 130 | array_new : `~numpy.ndarray` 131 | The reprojected array. 132 | footprint : `~numpy.ndarray` 133 | Footprint of the input array in the output array. Values of 0 indicate 134 | no coverage or valid values in the input image, while values of 1 135 | indicate valid values. 136 | """ 137 | 138 | array_in, wcs_in = parse_input_data(input_data, hdu_in=hdu_in) 139 | coord_system_out = parse_coord_system(coord_system_out) 140 | 141 | if ( 142 | has_celestial(wcs_in) 143 | and wcs_in.low_level_wcs.pixel_n_dim == 2 144 | and wcs_in.low_level_wcs.world_n_dim == 2 145 | ): 146 | return image_to_healpix( 147 | array_in, wcs_in, coord_system_out, nside=nside, order=order, nested=nested 148 | ) 149 | else: 150 | raise NotImplementedError( 151 | "Only data with a 2-d celestial WCS can be reprojected to a HEALPIX projection" 152 | ) 153 | -------------------------------------------------------------------------------- /reproject/healpix/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/healpix/tests/__init__.py -------------------------------------------------------------------------------- /reproject/healpix/tests/data/bayestar.fits.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/healpix/tests/data/bayestar.fits.gz -------------------------------------------------------------------------------- /reproject/healpix/tests/data/reference_result.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/healpix/tests/data/reference_result.fits -------------------------------------------------------------------------------- /reproject/healpix/tests/test_healpix.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | 3 | import itertools 4 | import os 5 | 6 | import numpy as np 7 | import pytest 8 | from astropy.io import fits 9 | from astropy.wcs import WCS 10 | from astropy_healpix import nside_to_npix 11 | 12 | from ...interpolation.tests.test_core import as_high_level_wcs 13 | from ...tests.test_high_level import ALL_DTYPES 14 | from ..high_level import reproject_from_healpix, reproject_to_healpix 15 | from ..utils import parse_coord_system 16 | 17 | DATA = os.path.join(os.path.dirname(__file__), "data") 18 | 19 | 20 | def get_reference_header(overscan=1, oversample=2, nside=1): 21 | reference_header = fits.Header() 22 | reference_header.update( 23 | { 24 | "CDELT1": -180.0 / (oversample * 4 * nside), 25 | "CDELT2": 180.0 / (oversample * 4 * nside), 26 | "CRPIX1": overscan * oversample * 4 * nside, 27 | "CRPIX2": overscan * oversample * 2 * nside, 28 | "CRVAL1": 180.0, 29 | "CRVAL2": 0.0, 30 | "CTYPE1": "RA---CAR", 31 | "CTYPE2": "DEC--CAR", 32 | "CUNIT1": "deg", 33 | "CUNIT2": "deg", 34 | "NAXIS": 2, 35 | "NAXIS1": overscan * oversample * 8 * nside, 36 | "NAXIS2": overscan * oversample * 4 * nside, 37 | } 38 | ) 39 | 40 | return reference_header 41 | 42 | 43 | @pytest.mark.parametrize( 44 | "nside,nested,healpix_system,image_system,dtype,order", 45 | itertools.product( 46 | [1, 2, 4, 8, 16, 32, 64], 47 | [True, False], 48 | "C", 49 | "C", 50 | ALL_DTYPES, 51 | ["bilinear", "nearest-neighbor"], 52 | ), 53 | ) 54 | def test_reproject_healpix_to_image_footprint( 55 | nside, nested, healpix_system, image_system, dtype, order 56 | ): 57 | """Test that HEALPix->WCS conversion correctly flags pixels that do not 58 | have valid WCS coordinates.""" 59 | 60 | npix = nside_to_npix(nside) 61 | healpix_data = np.random.uniform(size=npix).astype(dtype) 62 | 63 | reference_header = get_reference_header(overscan=2, oversample=2, nside=nside) 64 | 65 | wcs_out = WCS(reference_header) 66 | shape_out = reference_header["NAXIS2"], reference_header["NAXIS1"] 67 | 68 | image_data, footprint = reproject_from_healpix( 69 | (healpix_data, healpix_system), 70 | wcs_out, 71 | shape_out=shape_out, 72 | order=order, 73 | nested=nested, 74 | ) 75 | 76 | if order == "bilinear": 77 | expected_footprint = ~np.isnan(image_data) 78 | else: 79 | coord_system_in = parse_coord_system(healpix_system) 80 | yinds, xinds = np.indices(shape_out) 81 | world_in = wcs_out.pixel_to_world(xinds, yinds).transform_to(coord_system_in) 82 | world_in_unitsph = world_in.represent_as("unitspherical") 83 | lon_in, lat_in = world_in_unitsph.lon, world_in_unitsph.lat 84 | expected_footprint = ~(np.isnan(lon_in) | np.isnan(lat_in)) 85 | 86 | np.testing.assert_array_equal(footprint, expected_footprint) 87 | 88 | 89 | @pytest.mark.parametrize( 90 | "wcsapi,nside,nested,healpix_system,image_system,dtype", 91 | itertools.product([True, False], [1, 2, 4, 8, 16, 32, 64], [True, False], "C", "C", ALL_DTYPES), 92 | ) 93 | def test_reproject_healpix_to_image_round_trip( 94 | wcsapi, nside, nested, healpix_system, image_system, dtype 95 | ): 96 | """Test round-trip HEALPix->WCS->HEALPix conversion for a random map, 97 | with a WCS projection large enough to store each HEALPix pixel""" 98 | 99 | npix = nside_to_npix(nside) 100 | healpix_data = np.random.uniform(size=npix).astype(dtype) 101 | 102 | reference_header = get_reference_header(oversample=2, nside=nside) 103 | 104 | wcs_out = WCS(reference_header) 105 | shape_out = reference_header["NAXIS2"], reference_header["NAXIS1"] 106 | 107 | if wcsapi: 108 | wcs_out = as_high_level_wcs(wcs_out) 109 | 110 | image_data, footprint = reproject_from_healpix( 111 | (healpix_data, healpix_system), 112 | wcs_out, 113 | shape_out=shape_out, 114 | order="nearest-neighbor", 115 | nested=nested, 116 | ) 117 | 118 | healpix_data_2, footprint = reproject_to_healpix( 119 | (image_data, wcs_out), healpix_system, nside=nside, order="nearest-neighbor", nested=nested 120 | ) 121 | 122 | np.testing.assert_array_equal(healpix_data, healpix_data_2) 123 | 124 | 125 | def test_reproject_file(): 126 | reference_header = get_reference_header(oversample=2, nside=8) 127 | data, footprint = reproject_from_healpix( 128 | os.path.join(DATA, "bayestar.fits.gz"), reference_header 129 | ) 130 | reference_result = fits.getdata(os.path.join(DATA, "reference_result.fits")) 131 | np.testing.assert_allclose(data, reference_result, rtol=1.0e-5) 132 | 133 | 134 | def test_reproject_invalid_order(): 135 | reference_header = get_reference_header(oversample=2, nside=8) 136 | with pytest.raises(ValueError) as exc: 137 | reproject_from_healpix( 138 | os.path.join(DATA, "bayestar.fits.gz"), reference_header, order="bicubic" 139 | ) 140 | assert exc.value.args[0] == "Only nearest-neighbor and bilinear interpolation are supported" 141 | 142 | 143 | def test_reproject_to_healpix_input_types(valid_celestial_input_data): 144 | array_ref, wcs_in_ref, input_value, kwargs_in = valid_celestial_input_data 145 | 146 | # Compute reference 147 | 148 | healpix_data_ref, footprint_ref = reproject_to_healpix((array_ref, wcs_in_ref), "C", nside=64) 149 | 150 | # Compute test 151 | 152 | healpix_data_test, footprint_test = reproject_to_healpix( 153 | input_value, "C", nside=64, **kwargs_in 154 | ) 155 | 156 | # Make sure there are some valid values 157 | 158 | assert np.sum(~np.isnan(healpix_data_ref)) == 4 159 | 160 | np.testing.assert_allclose(healpix_data_ref, healpix_data_test) 161 | np.testing.assert_allclose(footprint_ref, footprint_test) 162 | 163 | 164 | def test_reproject_from_healpix_output_types(valid_celestial_output_projections): 165 | wcs_out_ref, shape_ref, output_value, kwargs_out = valid_celestial_output_projections 166 | 167 | array_input = np.random.random(12 * 64**2) 168 | 169 | # Compute reference 170 | 171 | output_ref, footprint_ref = reproject_from_healpix( 172 | (array_input, "C"), wcs_out_ref, nested=True, shape_out=shape_ref 173 | ) 174 | 175 | # Compute test 176 | 177 | output_test, footprint_test = reproject_from_healpix( 178 | (array_input, "C"), output_value, nested=True, **kwargs_out 179 | ) 180 | 181 | np.testing.assert_allclose(output_ref, output_test) 182 | np.testing.assert_allclose(footprint_ref, footprint_test) 183 | 184 | 185 | def test_reproject_to_healpix_exact_allsky(): 186 | 187 | # Regression test for a bug that caused artifacts in the final image if the 188 | # WCS covered the whole sky - this was due to using scipy's map_coordinates 189 | # one instead of our built-in one which deals properly with the pixels 190 | # around the rim. 191 | 192 | shape_out = (160, 320) 193 | wcs = WCS(naxis=2) 194 | wcs.wcs.crpix = [(shape_out[1] + 1) / 2, (shape_out[0] + 1) / 2] 195 | wcs.wcs.cdelt = np.array([-360.0 / shape_out[1], 180.0 / shape_out[0]]) 196 | wcs.wcs.crval = [0, 0] 197 | wcs.wcs.ctype = ["RA---CAR", "DEC--CAR"] 198 | 199 | array = np.ones(shape_out) 200 | 201 | healpix_array, footprint = reproject_to_healpix( 202 | (array, wcs), 203 | coord_system_out="galactic", 204 | nside=64, 205 | nested=False, 206 | order="bilinear", 207 | ) 208 | 209 | assert np.all(footprint > 0) 210 | assert not np.any(np.isnan(healpix_array)) 211 | -------------------------------------------------------------------------------- /reproject/healpix/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from astropy.coordinates import FK5, Galactic 4 | from astropy.io import fits 5 | 6 | from reproject.healpix.utils import parse_coord_system, parse_input_healpix_data 7 | 8 | 9 | def test_parse_coord_system(): 10 | frame = parse_coord_system(Galactic()) 11 | assert isinstance(frame, Galactic) 12 | 13 | frame = parse_coord_system("fk5") 14 | assert isinstance(frame, FK5) 15 | 16 | with pytest.raises(ValueError) as exc: 17 | frame = parse_coord_system("e") 18 | assert exc.value.args[0] == "Ecliptic coordinate frame not yet supported" 19 | 20 | frame = parse_coord_system("g") 21 | assert isinstance(frame, Galactic) 22 | 23 | with pytest.raises(ValueError) as exc: 24 | frame = parse_coord_system("spam") 25 | assert exc.value.args[0] == "Could not determine frame for system=spam" 26 | 27 | 28 | @pytest.mark.filterwarnings("ignore:unclosed file:ResourceWarning") 29 | def test_parse_input_healpix_data(tmpdir): 30 | data = np.arange(3072) 31 | 32 | col = fits.Column(array=data, name="flux", format="E") 33 | hdu = fits.BinTableHDU.from_columns([col]) 34 | hdu.header["NSIDE"] = 512 35 | hdu.header["COORDSYS"] = "G" 36 | 37 | # As HDU 38 | array, coordinate_system, nested = parse_input_healpix_data(hdu) 39 | np.testing.assert_allclose(array, data) 40 | 41 | # As filename 42 | filename = tmpdir.join("test.fits").strpath 43 | hdu.writeto(filename) 44 | array, coordinate_system, nested = parse_input_healpix_data(filename) 45 | np.testing.assert_allclose(array, data) 46 | 47 | # As array 48 | array, coordinate_system, nested = parse_input_healpix_data((data, "galactic")) 49 | np.testing.assert_allclose(array, data) 50 | 51 | # Invalid 52 | with pytest.raises(TypeError) as exc: 53 | parse_input_healpix_data(data) 54 | assert exc.value.args[0] == ( 55 | "input_data should either be an HDU object or a tuple of (array, frame)" 56 | ) 57 | -------------------------------------------------------------------------------- /reproject/healpix/utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from astropy.coordinates import ( 3 | ICRS, 4 | BaseCoordinateFrame, 5 | Galactic, 6 | frame_transform_graph, 7 | ) 8 | from astropy.io import fits 9 | from astropy.io.fits import BinTableHDU, TableHDU 10 | 11 | FRAMES = {"g": Galactic(), "c": ICRS()} 12 | 13 | 14 | def parse_coord_system(system): 15 | if isinstance(system, BaseCoordinateFrame): 16 | return system 17 | elif isinstance(system, str): 18 | system = system.lower() 19 | if system == "e": 20 | raise ValueError("Ecliptic coordinate frame not yet supported") 21 | elif system in FRAMES: 22 | return FRAMES[system] 23 | else: 24 | system_new = frame_transform_graph.lookup_name(system) 25 | if system_new is None: 26 | raise ValueError(f"Could not determine frame for system={system}") 27 | else: 28 | return system_new() 29 | 30 | 31 | def parse_input_healpix_data(input_data, field=0, hdu_in=None, nested=None): 32 | """ 33 | Parse input HEALPIX data to return a Numpy array and coordinate frame object. 34 | """ 35 | 36 | if isinstance(input_data, TableHDU | BinTableHDU): 37 | data = input_data.data 38 | header = input_data.header 39 | coordinate_system_in = parse_coord_system(header["COORDSYS"]) 40 | array_in = data[data.columns[field].name].ravel() 41 | if "ORDERING" in header: 42 | nested = header["ORDERING"].lower() == "nested" 43 | elif isinstance(input_data, str): 44 | # NOTE: hdu is not closed here. 45 | hdu = fits.open(input_data)[hdu_in or 1] 46 | return parse_input_healpix_data(hdu, field=field) 47 | elif isinstance(input_data, tuple) and isinstance(input_data[0], np.ndarray): 48 | array_in = input_data[0] 49 | coordinate_system_in = parse_coord_system(input_data[1]) 50 | else: 51 | raise TypeError("input_data should either be an HDU object or a tuple of (array, frame)") 52 | 53 | return array_in, coordinate_system_in, nested 54 | -------------------------------------------------------------------------------- /reproject/interpolation/__init__.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | """ 3 | Routines to carry out reprojection by interpolation. 4 | """ 5 | from .high_level import * # noqa 6 | -------------------------------------------------------------------------------- /reproject/interpolation/core.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | 3 | import numpy as np 4 | from astropy.wcs import WCS 5 | from astropy.wcs.utils import pixel_to_pixel 6 | 7 | from ..array_utils import map_coordinates 8 | from ..wcs_utils import has_celestial, pixel_to_pixel_with_roundtrip 9 | 10 | 11 | def _validate_wcs(wcs_in, wcs_out, shape_in, shape_out): 12 | if wcs_in.low_level_wcs.pixel_n_dim != wcs_out.low_level_wcs.pixel_n_dim: 13 | raise ValueError("Number of dimensions in input and output WCS should match") 14 | elif len(shape_out) < wcs_out.low_level_wcs.pixel_n_dim: 15 | raise ValueError("Too few dimensions in shape_out") 16 | elif len(shape_in) < wcs_in.low_level_wcs.pixel_n_dim: 17 | raise ValueError("Too few dimensions in input data") 18 | elif len(shape_in) != len(shape_out): 19 | raise ValueError("Number of dimensions in input and output data should match") 20 | 21 | # Separate the "extra" dimensions that don't correspond to a WCS axis and 22 | # which we'll be looping over 23 | extra_dimens_in = shape_in[: -wcs_in.low_level_wcs.pixel_n_dim] 24 | extra_dimens_out = shape_out[: -wcs_out.low_level_wcs.pixel_n_dim] 25 | if extra_dimens_in != extra_dimens_out: 26 | raise ValueError("Dimensions to be looped over must match exactly") 27 | 28 | if has_celestial(wcs_in) and not has_celestial(wcs_out): 29 | raise ValueError("Input WCS has celestial components but output WCS does not") 30 | elif has_celestial(wcs_out) and not has_celestial(wcs_in): 31 | raise ValueError("Output WCS has celestial components but input WCS does not") 32 | 33 | if isinstance(wcs_in, WCS) and isinstance(wcs_out, WCS): 34 | # Check whether a spectral component is present, and if so, check that 35 | # the CTYPEs match. 36 | if wcs_in.wcs.spec >= 0 and wcs_out.wcs.spec >= 0: 37 | if wcs_in.wcs.ctype[wcs_in.wcs.spec] != wcs_out.wcs.ctype[wcs_out.wcs.spec]: 38 | raise ValueError( 39 | f"The input ({wcs_in.wcs.ctype[wcs_in.wcs.spec]}) and output ({wcs_out.wcs.ctype[wcs_out.wcs.spec]}) spectral " 40 | "coordinate types are not equivalent." 41 | ) 42 | elif wcs_in.wcs.spec >= 0: 43 | raise ValueError("Input WCS has a spectral component but output WCS does not") 44 | elif wcs_out.wcs.spec >= 0: 45 | raise ValueError("Output WCS has a spectral component but input WCS does not") 46 | 47 | 48 | def _reproject_full( 49 | array, 50 | wcs_in, 51 | wcs_out, 52 | shape_out, 53 | order=1, 54 | array_out=None, 55 | return_footprint=True, 56 | roundtrip_coords=True, 57 | output_footprint=None, 58 | ): 59 | """ 60 | Reproject n-dimensional data to a new projection using interpolation. 61 | 62 | The input and output WCS and shape have to satisfy a number of conditions: 63 | 64 | - The number of dimensions in each WCS should match 65 | - The output shape should match the dimensionality of the WCS 66 | - The input and output WCS should have matching physical types, although 67 | the order can be different as long as the physical types are unique. 68 | 69 | If the input array contains extra dimensions beyond what the input WCS has, 70 | the extra leading dimensions are assumed to represent multiple images with 71 | the same coordinate information. The transformation is computed once and 72 | "broadcast" across those images. 73 | """ 74 | 75 | # shape_out must be exactly a tuple type 76 | shape_out = tuple(shape_out) 77 | _validate_wcs(wcs_in, wcs_out, array.shape, shape_out) 78 | 79 | if array_out is None: 80 | array_out = np.empty(shape_out) 81 | 82 | if output_footprint is None: 83 | output_footprint = np.empty(shape_out) 84 | 85 | array_out_loopable = array_out 86 | if len(array.shape) == wcs_in.low_level_wcs.pixel_n_dim: 87 | # We don't need to broadcast the transformation over any extra 88 | # axes---add an extra axis of length one just so we have something 89 | # to loop over in all cases. 90 | array = array.reshape((1, *array.shape)) 91 | array_out_loopable = array_out.reshape((1, *array_out.shape)) 92 | elif len(array.shape) > wcs_in.low_level_wcs.pixel_n_dim: 93 | # We're broadcasting. Flatten the extra dimensions so there's just one 94 | # to loop over 95 | array = array.reshape((-1, *array.shape[-wcs_in.low_level_wcs.pixel_n_dim :])) 96 | array_out_loopable = array_out.reshape( 97 | (-1, *array_out.shape[-wcs_out.low_level_wcs.pixel_n_dim :]) 98 | ) 99 | else: 100 | raise ValueError("Too few dimensions for input array") 101 | 102 | wcs_dims = shape_out[-wcs_in.low_level_wcs.pixel_n_dim :] 103 | pixel_out = np.meshgrid( 104 | *[np.arange(size, dtype=float) for size in wcs_dims], 105 | indexing="ij", 106 | sparse=False, 107 | copy=False, 108 | ) 109 | pixel_out = [p.ravel() for p in pixel_out] 110 | # For each pixel in the output array, get the pixel value in the input WCS 111 | if roundtrip_coords: 112 | pixel_in = pixel_to_pixel_with_roundtrip(wcs_out, wcs_in, *pixel_out[::-1])[::-1] 113 | else: 114 | pixel_in = pixel_to_pixel(wcs_out, wcs_in, *pixel_out[::-1])[::-1] 115 | pixel_in = np.array(pixel_in) 116 | 117 | # Loop over the broadcasted dimensions in our array, reusing the same 118 | # computed transformation each time 119 | for i in range(len(array)): 120 | # Interpolate array on to the pixels coordinates in pixel_in 121 | map_coordinates( 122 | array[i], 123 | pixel_in, 124 | order=order, 125 | cval=np.nan, 126 | mode="constant", 127 | output=array_out_loopable[i].ravel(), 128 | max_chunk_size=256 * 1024**2, 129 | ) 130 | 131 | # n.b. We write the reprojected data into array_out_loopable, but array_out 132 | # also contains this data and has the user's desired output shape. 133 | 134 | if return_footprint: 135 | output_footprint[:] = (~np.isnan(array_out)).astype(float) 136 | return array_out, output_footprint 137 | else: 138 | return array_out 139 | -------------------------------------------------------------------------------- /reproject/interpolation/high_level.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | 3 | 4 | from ..common import _reproject_dispatcher 5 | from ..utils import parse_input_data, parse_output_projection 6 | from .core import _reproject_full 7 | 8 | __all__ = ["reproject_interp"] 9 | 10 | ORDER = {} 11 | ORDER["nearest-neighbor"] = 0 12 | ORDER["bilinear"] = 1 13 | ORDER["biquadratic"] = 2 14 | ORDER["bicubic"] = 3 15 | 16 | 17 | def reproject_interp( 18 | input_data, 19 | output_projection, 20 | shape_out=None, 21 | hdu_in=0, 22 | order="bilinear", 23 | roundtrip_coords=True, 24 | output_array=None, 25 | output_footprint=None, 26 | return_footprint=True, 27 | block_size=None, 28 | parallel=False, 29 | return_type=None, 30 | ): 31 | """ 32 | Reproject data to a new projection using interpolation (this is typically 33 | the fastest way to reproject an image). 34 | 35 | Parameters 36 | ---------- 37 | input_data : object 38 | The input data to reproject. This can be: 39 | 40 | * The name of a FITS file as a `str` or a `pathlib.Path` object 41 | * An `~astropy.io.fits.HDUList` object 42 | * An image HDU object such as a `~astropy.io.fits.PrimaryHDU`, 43 | `~astropy.io.fits.ImageHDU`, or `~astropy.io.fits.CompImageHDU` 44 | instance 45 | * A tuple where the first element is a `~numpy.ndarray` and the 46 | second element is either a 47 | `~astropy.wcs.wcsapi.BaseLowLevelWCS`, 48 | `~astropy.wcs.wcsapi.BaseHighLevelWCS`, or a 49 | `~astropy.io.fits.Header` object 50 | * An `~astropy.nddata.NDData` object from which the ``.data`` and 51 | ``.wcs`` attributes will be used as the input data. 52 | 53 | If the data array contains more dimensions than are described by the 54 | input header or WCS, the extra dimensions (assumed to be the first 55 | dimensions) are taken to represent multiple images with the same 56 | coordinate information. The coordinate transformation will be computed 57 | once and then each image will be reprojected, offering a speedup over 58 | reprojecting each image individually. 59 | output_projection : `~astropy.wcs.wcsapi.BaseLowLevelWCS` or `~astropy.wcs.wcsapi.BaseHighLevelWCS` or `~astropy.io.fits.Header` 60 | The output projection, which can be either a 61 | `~astropy.wcs.wcsapi.BaseLowLevelWCS`, 62 | `~astropy.wcs.wcsapi.BaseHighLevelWCS`, or a `~astropy.io.fits.Header` 63 | instance. 64 | shape_out : tuple, optional 65 | If ``output_projection`` is a WCS instance, the shape of the output 66 | data should be specified separately. 67 | hdu_in : int or str, optional 68 | If ``input_data`` is a FITS file or an `~astropy.io.fits.HDUList` 69 | instance, specifies the HDU to use. 70 | order : int or str, optional 71 | The order of the interpolation. This can be any of the 72 | following strings: 73 | 74 | * 'nearest-neighbor' 75 | * 'bilinear' 76 | * 'biquadratic' 77 | * 'bicubic' 78 | 79 | or an integer. A value of ``0`` indicates nearest neighbor 80 | interpolation. 81 | roundtrip_coords : bool 82 | Whether to verify that coordinate transformations are defined in both 83 | directions. 84 | output_array : None or `~numpy.ndarray` 85 | An array in which to store the reprojected data. This can be any numpy 86 | array including a memory map, which may be helpful when dealing with 87 | extremely large files. 88 | output_footprint : `~numpy.ndarray`, optional 89 | An array in which to store the footprint of reprojected data. This can be 90 | any numpy array including a memory map, which may be helpful when dealing with 91 | extremely large files. 92 | return_footprint : bool 93 | Whether to return the footprint in addition to the output array. 94 | block_size : tuple or 'auto', optional 95 | The size of blocks in terms of output array pixels that each block will handle 96 | reprojecting. Extending out from (0,0) coords positively, block sizes 97 | are clamped to output space edges when a block would extend past edge. 98 | Specifying ``'auto'`` means that reprojection will be done in blocks with 99 | the block size automatically determined. If ``block_size`` is not 100 | specified or set to `None`, the reprojection will not be carried out in 101 | blocks. 102 | parallel : bool or int or str, optional 103 | If `True`, the reprojection is carried out in parallel, and if a 104 | positive integer, this specifies the number of threads to use. 105 | The reprojection will be parallelized over output array blocks specified 106 | by ``block_size`` (if the block size is not set, it will be determined 107 | automatically). To use the currently active dask scheduler (e.g. 108 | dask.distributed), set this to ``'current-scheduler'``. 109 | return_type : {'numpy', 'dask'}, optional 110 | Whether to return numpy or dask arrays - defaults to 'numpy'. 111 | 112 | Returns 113 | ------- 114 | array_new : `~numpy.ndarray` 115 | The reprojected array. 116 | footprint : `~numpy.ndarray` 117 | Footprint of the input array in the output array. Values of 0 indicate 118 | no coverage or valid values in the input image, while values of 1 119 | indicate valid values. 120 | """ 121 | 122 | array_in, wcs_in = parse_input_data(input_data, hdu_in=hdu_in) 123 | wcs_out, shape_out = parse_output_projection( 124 | output_projection, shape_in=array_in.shape, shape_out=shape_out, output_array=output_array 125 | ) 126 | 127 | if isinstance(order, str): 128 | order = ORDER[order] 129 | 130 | # TODO: add tests that actually ensure that order and roundtrip_coords work 131 | 132 | return _reproject_dispatcher( 133 | _reproject_full, 134 | array_in=array_in, 135 | wcs_in=wcs_in, 136 | wcs_out=wcs_out, 137 | shape_out=shape_out, 138 | array_out=output_array, 139 | parallel=parallel, 140 | block_size=block_size, 141 | return_footprint=return_footprint, 142 | output_footprint=output_footprint, 143 | reproject_func_kwargs=dict( 144 | order=order, 145 | roundtrip_coords=roundtrip_coords, 146 | ), 147 | return_type=return_type, 148 | ) 149 | -------------------------------------------------------------------------------- /reproject/interpolation/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/interpolation/tests/__init__.py -------------------------------------------------------------------------------- /reproject/interpolation/tests/reference/test_reproject_celestial_2d_gal2equ.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/interpolation/tests/reference/test_reproject_celestial_2d_gal2equ.fits -------------------------------------------------------------------------------- /reproject/interpolation/tests/reference/test_reproject_celestial_3d_equ2gal.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/interpolation/tests/reference/test_reproject_celestial_3d_equ2gal.fits -------------------------------------------------------------------------------- /reproject/interpolation/tests/reference/test_reproject_roundtrip.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/interpolation/tests/reference/test_reproject_roundtrip.fits -------------------------------------------------------------------------------- /reproject/interpolation/tests/reference/test_small_cutout.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/interpolation/tests/reference/test_small_cutout.fits -------------------------------------------------------------------------------- /reproject/mosaicking/__init__.py: -------------------------------------------------------------------------------- 1 | from .coadd import * # noqa 2 | from .wcs_helpers import * # noqa 3 | -------------------------------------------------------------------------------- /reproject/mosaicking/background.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | 3 | from math import exp 4 | 5 | import numpy as np 6 | 7 | __all__ = ["solve_corrections_sgd", "determine_offset_matrix"] 8 | 9 | 10 | def determine_offset_matrix(arrays): 11 | """ 12 | Given a list of ReprojectedArraySubset, determine the offset 13 | matrix between all arrays. 14 | 15 | Parameters 16 | ---------- 17 | arrays : list 18 | The list of ReprojectedArraySubset objects to determine the offsets for. 19 | 20 | Returns 21 | ------- 22 | `numpy.ndarray` 23 | The offset matrix. 24 | """ 25 | 26 | N = len(arrays) 27 | 28 | # Set up matrix to record differences 29 | offset_matrix = np.ones((N, N)) * np.nan 30 | 31 | # Loop over all pairs of images and check for overlap 32 | for i1, array1 in enumerate(arrays): 33 | for i2, array2 in enumerate(arrays): 34 | if i2 <= i1: 35 | continue 36 | if array1.overlaps(array2): 37 | difference = array1 - array2 38 | if np.any(difference.footprint): 39 | values = difference.array[difference.footprint] 40 | offset_matrix[i1, i2] = np.median(values) 41 | offset_matrix[i2, i1] = -offset_matrix[i1, i2] 42 | 43 | return offset_matrix 44 | 45 | 46 | def solve_corrections_sgd(offset_matrix, eta_initial=1, eta_half_life=100, rtol=1e-10, atol=0): 47 | r""" 48 | Given a matrix of offsets from each image to each other image, find the 49 | optimal offsets to use using Stochastic Gradient Descent. 50 | 51 | Given N images, we can construct an NxN matrix Oij giving the typical (e.g. 52 | mean, median, or other statistic) offset from each image to each other 53 | image. This can be a reasonably sparse matrix since not all images 54 | necessarily overlap. From this we then want to find a vector of N 55 | corrections Ci to apply to each image to minimize the differences. 56 | 57 | We do this by using the Stochastic Gradient Descent algorithm: 58 | 59 | https://en.wikipedia.org/wiki/Stochastic_gradient_descent 60 | 61 | Essentially what we are trying to minimize is the difference between Dij 62 | and a matrix of the same shape constructed from the Oi values. 63 | 64 | The learning rate is decreased using a decaying exponential: 65 | 66 | $$\eta = \eta_{\rm initial} * \exp{(-i/t_{\eta})}$$ 67 | 68 | Parameters 69 | ---------- 70 | offset_matrix : `~numpy.ndarray` 71 | The NxN matrix giving the offsets between all images (or NaN if 72 | an offset could not be determined). 73 | eta_initial : float 74 | The initial learning rate to use. 75 | eta_half_life : float 76 | The number of iterations after which the learning rate should be 77 | decreased by a factor $e$. 78 | rtol : float 79 | The relative tolerance to use to determine if the corrections have 80 | converged. 81 | atol : float 82 | The absolute tolerance to use to determine if the corrections have 83 | converged. 84 | 85 | Returns 86 | ------- 87 | `numpy.ndarray` 88 | The corrections for each frame. 89 | """ 90 | 91 | if offset_matrix.ndim != 2 or offset_matrix.shape[0] != offset_matrix.shape[1]: 92 | raise ValueError("offset_matrix should be a square NxN matrix") 93 | 94 | N = offset_matrix.shape[0] 95 | 96 | indices = np.arange(N) 97 | corrections = np.zeros(N) 98 | 99 | # Keep track of previous corrections to know whether the algorithm 100 | # has converged 101 | previous_corrections = None 102 | 103 | for iteration in range(int(eta_half_life * 10)): 104 | # Shuffle the indices to avoid cyclical behavior 105 | np.random.shuffle(indices) 106 | 107 | # Update learning rate 108 | eta = eta_initial * exp(-iteration / eta_half_life) 109 | 110 | # Loop over each index and update the offset. What we call rows and 111 | # columns is arbitrary, but for the purpose of the comments below, we 112 | # treat this as iterating over rows of the matrix. 113 | for i in indices: 114 | if np.isnan(corrections[i]): 115 | continue 116 | 117 | # Since the matrix might be sparse, we consider only columns which 118 | # are not NaN 119 | keep = ~np.isnan(offset_matrix[i, :]) 120 | 121 | # Compute the row of the offset matrix one would get with the 122 | # current offsets 123 | fitted_offset_matrix_row = corrections[i] - corrections[keep] 124 | 125 | # The difference between the actual row in the matrix and this 126 | # fitted row gives us a measure of the gradient, so we then 127 | # adjust the solution in that direction. 128 | corrections[i] += eta * np.mean(offset_matrix[i, keep] - fitted_offset_matrix_row) 129 | 130 | # Subtract the mean offset from the offsets to make sure that the 131 | # corrections stay centered around zero 132 | corrections -= np.nanmean(corrections) 133 | 134 | if previous_corrections is not None: 135 | if np.allclose(corrections, previous_corrections, rtol=rtol, atol=atol): 136 | break # the algorithm has converged 137 | 138 | previous_corrections = corrections.copy() 139 | 140 | return corrections 141 | -------------------------------------------------------------------------------- /reproject/mosaicking/subset_array.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | 3 | import operator 4 | 5 | __all__ = ["ReprojectedArraySubset"] 6 | 7 | 8 | class ReprojectedArraySubset: 9 | # The aim of this class is to represent a subset of an array and 10 | # footprint extracted (or meant to represent extracted) versions 11 | # from larger arrays and footprints. 12 | 13 | # NOTE: we can't use Cutout2D here because it's much more convenient 14 | # to work with position being the lower left corner of the cutout 15 | # rather than the center, which is not well defined for even-sized 16 | # cutouts. 17 | 18 | def __init__(self, array, footprint, bounds): 19 | self.array = array 20 | self.footprint = footprint 21 | self.bounds = bounds 22 | 23 | def __repr__(self): 24 | bounds_str = "[" + ",".join(f"{imin}:{imax}" for (imin, imax) in self.bounds) + "]" 25 | return f"" 26 | 27 | @property 28 | def view_in_original_array(self): 29 | return tuple([slice(imin, imax) for (imin, imax) in self.bounds]) 30 | 31 | @property 32 | def shape(self): 33 | return tuple((imax - imin) for (imin, imax) in self.bounds) 34 | 35 | def overlaps(self, other): 36 | # Note that the use of <= or >= instead of < and > is due to 37 | # the fact that the max values are exclusive (so +1 above the 38 | # last value). 39 | if len(self.bounds) != len(other.bounds): 40 | raise ValueError( 41 | f"Mismatch in number of dimensions, expected " 42 | f"{len(self.bounds)} dimensions and got {len(other.bounds)}" 43 | ) 44 | for (imin, imax), (imin_other, imax_other) in zip(self.bounds, other.bounds, strict=False): 45 | if imax <= imin_other or imax_other <= imin: 46 | return False 47 | return True 48 | 49 | def __add__(self, other): 50 | return self._operation(other, operator.add) 51 | 52 | def __sub__(self, other): 53 | return self._operation(other, operator.sub) 54 | 55 | def __mul__(self, other): 56 | return self._operation(other, operator.mul) 57 | 58 | def __truediv__(self, other): 59 | return self._operation(other, operator.truediv) 60 | 61 | def _operation(self, other, op): 62 | if len(self.bounds) != len(other.bounds): 63 | raise ValueError( 64 | f"Mismatch in number of dimensions, expected " 65 | f"{len(self.bounds)} dimensions and got {len(other.bounds)}" 66 | ) 67 | 68 | # Determine cutout parameters for overlap region 69 | 70 | overlap_bounds = [] 71 | self_slices = [] 72 | other_slices = [] 73 | for (imin, imax), (imin_other, imax_other) in zip(self.bounds, other.bounds, strict=False): 74 | imin_overlap = max(imin, imin_other) 75 | imax_overlap = min(imax, imax_other) 76 | if imax_overlap < imin_overlap: 77 | imax_overlap = imin_overlap 78 | overlap_bounds.append((imin_overlap, imax_overlap)) 79 | self_slices.append(slice(imin_overlap - imin, imax_overlap - imin)) 80 | other_slices.append(slice(imin_overlap - imin_other, imax_overlap - imin_other)) 81 | 82 | self_slices = tuple(self_slices) 83 | 84 | self_array = self.array[self_slices] 85 | self_footprint = self.footprint[self_slices] 86 | 87 | other_slices = tuple(other_slices) 88 | 89 | other_array = other.array[other_slices] 90 | other_footprint = other.footprint[other_slices] 91 | 92 | # Carry out operator and store result in ReprojectedArraySubset 93 | 94 | array = op(self_array, other_array) 95 | footprint = (self_footprint > 0) & (other_footprint > 0) 96 | 97 | return ReprojectedArraySubset(array, footprint, overlap_bounds) 98 | -------------------------------------------------------------------------------- /reproject/mosaicking/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/mosaicking/tests/__init__.py -------------------------------------------------------------------------------- /reproject/mosaicking/tests/reference/test_coadd_solar_map.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/mosaicking/tests/reference/test_coadd_solar_map.fits -------------------------------------------------------------------------------- /reproject/mosaicking/tests/test_background.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | 3 | import numpy as np 4 | import pytest 5 | from numpy.testing import assert_allclose 6 | 7 | from ..background import solve_corrections_sgd 8 | 9 | # Try and cover a range of matrix sizes and absolute scales of corrections 10 | CASES = [(4, 1.0), (33, 1e30), (44, 1e-50), (132, 1e10), (1441, 1e-5)] 11 | 12 | 13 | @pytest.mark.parametrize(("N", "scale"), CASES) 14 | def test_solve_corrections_sgd(N, scale): 15 | # Generate random corrections 16 | expected = np.random.uniform(-scale, scale, N) 17 | 18 | # Generate offsets matrix 19 | offset_matrix = expected[:, np.newaxis] - expected[np.newaxis, :] 20 | 21 | # Add some NaN values 22 | offset_matrix[1, 2] = np.nan 23 | 24 | # Determine corrections 25 | actual = solve_corrections_sgd(offset_matrix) 26 | 27 | # Compare the mean-subtracted corrections since there might be an 28 | # arbitrary offset 29 | assert_allclose(actual - np.mean(actual), expected - np.mean(expected)) 30 | -------------------------------------------------------------------------------- /reproject/mosaicking/tests/test_subset_array.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | 3 | import operator 4 | 5 | import numpy as np 6 | import pytest 7 | from numpy.testing import assert_equal 8 | 9 | from ..subset_array import ReprojectedArraySubset 10 | 11 | 12 | class TestReprojectedArraySubset: 13 | def setup_method(self, method): 14 | self.array1 = np.random.random((123, 87)) 15 | self.array2 = np.random.random((123, 87)) 16 | self.array3 = np.random.random((123, 87)) 17 | self.array4 = np.random.random((123, 87, 16)) 18 | 19 | self.footprint1 = (self.array1 > 0.5).astype(int) 20 | self.footprint2 = (self.array2 > 0.5).astype(int) 21 | self.footprint3 = (self.array3 > 0.5).astype(int) 22 | self.footprint4 = (self.array4 > 0.5).astype(int) 23 | 24 | self.subset1 = ReprojectedArraySubset( 25 | self.array1[20:88, 34:40], 26 | self.footprint1[20:88, 34:40], 27 | [(20, 88), (34, 40)], 28 | ) 29 | 30 | self.subset2 = ReprojectedArraySubset( 31 | self.array2[50:123, 37:42], 32 | self.footprint2[50:123, 37:42], 33 | [(50, 123), (37, 42)], 34 | ) 35 | 36 | self.subset3 = ReprojectedArraySubset( 37 | self.array3[40:50, 11:19], 38 | self.footprint3[40:50, 11:19], 39 | [(40, 50), (11, 19)], 40 | ) 41 | 42 | self.subset4 = ReprojectedArraySubset( 43 | self.array4[30:35, 40:45, 1:4], 44 | self.footprint4[30:35, 40:45, 1:4], 45 | [(30, 35), (40, 45), (1, 4)], 46 | ) 47 | 48 | def test_repr(self): 49 | assert repr(self.subset1) == "" 50 | 51 | def test_view_in_original_array(self): 52 | assert_equal(self.array1[self.subset1.view_in_original_array], self.subset1.array) 53 | assert_equal(self.footprint1[self.subset1.view_in_original_array], self.subset1.footprint) 54 | 55 | def test_shape(self): 56 | assert self.subset1.shape == (68, 6) 57 | 58 | def test_overlaps(self): 59 | assert self.subset1.overlaps(self.subset1) 60 | assert self.subset1.overlaps(self.subset2) 61 | assert not self.subset1.overlaps(self.subset3) 62 | assert self.subset2.overlaps(self.subset1) 63 | assert self.subset2.overlaps(self.subset2) 64 | assert not self.subset2.overlaps(self.subset3) 65 | assert not self.subset3.overlaps(self.subset1) 66 | assert not self.subset3.overlaps(self.subset2) 67 | assert self.subset3.overlaps(self.subset3) 68 | 69 | @pytest.mark.parametrize("op", [operator.add, operator.sub, operator.mul, operator.truediv]) 70 | def test_arithmetic(self, op): 71 | subset = op(self.subset1, self.subset2) 72 | assert subset.bounds == [(50, 88), (37, 40)] 73 | expected = op(self.array1[50:88, 37:40], self.array2[50:88, 37:40]) 74 | assert_equal(subset.array, expected) 75 | 76 | def test_arithmetic_nooverlap(self): 77 | subset = self.subset1 - self.subset3 78 | assert subset.bounds == [(40, 50), (34, 34)] 79 | assert subset.shape == (10, 0) 80 | 81 | def test_overlaps_dimension_mismatch(self): 82 | with pytest.raises( 83 | ValueError, match=("Mismatch in number of dimensions, expected 2 dimensions and got 3") 84 | ): 85 | self.subset1.overlaps(self.subset4) 86 | 87 | def test_arithmetic_dimension_mismatch(self): 88 | with pytest.raises( 89 | ValueError, match=("Mismatch in number of dimensions, expected 2 dimensions and got 3") 90 | ): 91 | self.subset1 - self.subset4 92 | -------------------------------------------------------------------------------- /reproject/spherical_intersect/__init__.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | """ 3 | Routines to compute pixel overlap areas. 4 | """ 5 | from .high_level import * # noqa 6 | -------------------------------------------------------------------------------- /reproject/spherical_intersect/_overlap.pyx: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | cimport numpy as np 3 | import cython 4 | 5 | ctypedef np.double_t DOUBLE_T 6 | 7 | cdef extern from "overlapArea.h": 8 | double computeOverlap(double * ilon, double * ilat, double * olon, double * olat, 9 | int energyMode, double refArea, double * areaRatio) 10 | 11 | cdef extern from "reproject_slice_c.h": 12 | void _reproject_slice_c(int startx, int endx, int starty, int endy, int nx_out, int ny_out, 13 | double *xp_inout, double *yp_inout, double *xw_in, double *yw_in, double *xw_out, double *yw_out, 14 | double *array, double *array_new, double *weights, 15 | int col_in, int col_out, int col_array, int col_new) 16 | 17 | # @cython.wraparound(False) 18 | # @cython.boundscheck(False) 19 | def _reproject_slice_cython(int startx, int endx, int starty, int endy, int nx_out, int ny_out, 20 | np.ndarray[double, ndim=2, mode = "c"] xp_inout, 21 | np.ndarray[double, ndim=2, mode = "c"] yp_inout, 22 | np.ndarray[double, ndim=2, mode = "c"] xw_in, 23 | np.ndarray[double, ndim=2, mode = "c"] yw_in, 24 | np.ndarray[double, ndim=2, mode = "c"] xw_out, 25 | np.ndarray[double, ndim=2, mode = "c"] yw_out, 26 | np.ndarray[double, ndim=2, mode = "c"] array, 27 | shape_out): 28 | 29 | # Create the array_new and weights objects, plus the objects needed in the loop. 30 | cdef np.ndarray[double, ndim = 2, mode = "c"] array_new = np.zeros(shape_out, dtype = np.double) 31 | cdef np.ndarray[double, ndim = 2, mode = "c"] weights = np.zeros(shape_out, dtype = np.double) 32 | 33 | # We need the y size of these 2-dimensional arrays in order to access the elements correctly 34 | # from raw C. 35 | cdef int col_in = xw_in.shape[1] 36 | cdef int col_out = xw_out.shape[1] 37 | cdef int col_array = array.shape[1] 38 | cdef int col_new = array_new.shape[1] 39 | 40 | # Call the C function now. 41 | _reproject_slice_c(startx,endx,starty,endy,nx_out,ny_out, 42 | &xp_inout[0,0],&yp_inout[0,0], 43 | &xw_in[0,0],&yw_in[0,0],&xw_out[0,0],&yw_out[0,0],&array[0,0], 44 | &array_new[0,0],&weights[0,0], 45 | col_in,col_out,col_array,col_new) 46 | 47 | return array_new,weights 48 | 49 | # @cython.wraparound(False) 50 | # @cython.boundscheck(False) 51 | def _compute_overlap(np.ndarray[double, ndim=2] ilon, 52 | np.ndarray[double, ndim=2] ilat, 53 | np.ndarray[double, ndim=2] olon, 54 | np.ndarray[double, ndim=2] olat): 55 | cdef int i 56 | cdef int n = ilon.shape[0] 57 | 58 | cdef np.ndarray[double, ndim = 1] overlap = np.empty(n, dtype=np.double) 59 | cdef np.ndarray[double, ndim = 1] area_ratio = np.empty(n, dtype=np.double) 60 | 61 | for i in range(n): 62 | overlap[i] = computeOverlap(& ilon[i, 0], & ilat[i, 0], & olon[i, 0], & olat[i, 0], 63 | 0, 1, & area_ratio[i]) 64 | 65 | return overlap, area_ratio 66 | -------------------------------------------------------------------------------- /reproject/spherical_intersect/core.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | 3 | import warnings 4 | 5 | import numpy as np 6 | from astropy import units as u 7 | from astropy.wcs import WCS 8 | from astropy.wcs.utils import proj_plane_pixel_area 9 | 10 | from ._overlap import _reproject_slice_cython 11 | 12 | 13 | def _reproject_celestial( 14 | array, 15 | wcs_in, 16 | wcs_out, 17 | shape_out, 18 | array_out=None, 19 | output_footprint=None, 20 | return_footprint=True, 21 | ): 22 | if array_out is None: 23 | array_out = np.empty(shape_out) 24 | 25 | if output_footprint is None: 26 | output_footprint = np.empty(shape_out) 27 | 28 | # There are currently precision issues below certain resolutions, so we 29 | # emit a warning if this is the case. For more details, see: 30 | # https://github.com/astropy/reproject/issues/199 31 | area_threshold = (0.05 / 3600) ** 2 32 | if (isinstance(wcs_in, WCS) and proj_plane_pixel_area(wcs_in) < area_threshold) or ( 33 | isinstance(wcs_out, WCS) and proj_plane_pixel_area(wcs_out) < area_threshold 34 | ): 35 | warnings.warn( 36 | "The reproject_exact function currently has precision " 37 | "issues with images that have resolutions below ~0.05 " 38 | "arcsec, so the results may not be accurate.", 39 | UserWarning, 40 | stacklevel=2, 41 | ) 42 | 43 | # Convert input array to float values. If this comes from a FITS, it might have 44 | # float32 as value type and that can break things in Cython 45 | array = np.asarray(array, dtype=float) 46 | shape_out = tuple(shape_out) 47 | 48 | if wcs_in.pixel_n_dim != 2: 49 | # TODO: make this work for n-dimensional arrays 50 | raise NotImplementedError("Only 2-dimensional arrays can be reprojected at this time") 51 | elif len(shape_out) < wcs_out.low_level_wcs.pixel_n_dim: 52 | raise ValueError("Too few dimensions in shape_out") 53 | elif len(array.shape) < wcs_in.low_level_wcs.pixel_n_dim: 54 | raise ValueError("Too few dimensions in input data") 55 | elif len(array.shape) != len(shape_out): 56 | raise ValueError("Number of dimensions in input and output data should match") 57 | 58 | # Separate the "extra" dimensions that don't correspond to a WCS axis and 59 | # which we'll be looping over 60 | extra_dimens_in = array.shape[: -wcs_in.low_level_wcs.pixel_n_dim] 61 | extra_dimens_out = shape_out[: -wcs_out.low_level_wcs.pixel_n_dim] 62 | if extra_dimens_in != extra_dimens_out: 63 | raise ValueError("Dimensions to be looped over must match exactly") 64 | 65 | # TODO: at the moment, we compute the coordinates of all of the corners, 66 | # but we might want to do it in steps for large images. 67 | 68 | # Start off by finding the world position of all the corners of the input 69 | # image in world coordinates 70 | 71 | ny_in, nx_in = array.shape[-2:] 72 | 73 | x = np.arange(nx_in + 1.0) - 0.5 74 | y = np.arange(ny_in + 1.0) - 0.5 75 | 76 | xp_in, yp_in = np.meshgrid(x, y, indexing="xy", sparse=False, copy=False) 77 | 78 | world_in = wcs_in.pixel_to_world(xp_in, yp_in) 79 | 80 | # Now compute the world positions of all the corners in the output header 81 | 82 | ny_out, nx_out = shape_out[-2:] 83 | 84 | x = np.arange(nx_out + 1.0) - 0.5 85 | y = np.arange(ny_out + 1.0) - 0.5 86 | 87 | xp_out, yp_out = np.meshgrid(x, y, indexing="xy", sparse=False, copy=False) 88 | 89 | world_out = wcs_out.pixel_to_world(xp_out, yp_out) 90 | 91 | # Convert the input world coordinates to the frame of the output world 92 | # coordinates. 93 | 94 | world_in = world_in.transform_to(world_out.frame) 95 | 96 | # Finally, compute the pixel positions in the *output* image of the pixels 97 | # from the *input* image. 98 | 99 | xp_inout, yp_inout = wcs_out.world_to_pixel(world_in) 100 | 101 | world_in_unitsph = world_in.represent_as("unitspherical") 102 | xw_in, yw_in = world_in_unitsph.lon.to_value(u.deg), world_in_unitsph.lat.to_value(u.deg) 103 | 104 | world_out_unitsph = world_out.represent_as("unitspherical") 105 | xw_out, yw_out = world_out_unitsph.lon.to_value(u.deg), world_out_unitsph.lat.to_value(u.deg) 106 | 107 | # If the input array contains extra dimensions beyond what the input WCS 108 | # has, the extra leading dimensions are assumed to represent multiple 109 | # images with the same coordinate information. The transformation is 110 | # computed once and "broadcast" across those images. 111 | if len(array.shape) == wcs_in.low_level_wcs.pixel_n_dim: 112 | # We don't need to broadcast the transformation over any extra 113 | # axes---add an extra axis of length one just so we have something 114 | # to loop over in all cases. 115 | broadcasting = False 116 | array = array.reshape((1, *array.shape)) 117 | elif len(array.shape) > wcs_in.low_level_wcs.pixel_n_dim: 118 | # We're broadcasting. Flatten the extra dimensions so there's just one 119 | # to loop over 120 | broadcasting = True 121 | array = array.reshape((-1, *array.shape[-wcs_in.low_level_wcs.pixel_n_dim :])) 122 | array_out = array_out.reshape((-1, *shape_out[-2:])) 123 | output_footprint = output_footprint.reshape((-1, *shape_out[-2:])) 124 | else: 125 | raise ValueError("Too few dimensions for input array") 126 | 127 | array = np.ascontiguousarray(array) 128 | 129 | for i in range(len(array)): 130 | array_new, weights = _reproject_slice_cython( 131 | 0, 132 | nx_in, 133 | 0, 134 | ny_in, 135 | nx_out, 136 | ny_out, 137 | np.ascontiguousarray(xp_inout), 138 | np.ascontiguousarray(yp_inout), 139 | np.ascontiguousarray(xw_in), 140 | np.ascontiguousarray(yw_in), 141 | np.ascontiguousarray(xw_out), 142 | np.ascontiguousarray(yw_out), 143 | array[i], 144 | shape_out[-2:], 145 | ) 146 | 147 | with np.errstate(invalid="ignore"): 148 | array_new /= weights 149 | 150 | if broadcasting: 151 | array_out[i] = array_new 152 | if return_footprint: 153 | output_footprint[i] = weights 154 | else: 155 | array_out[:] = array_new 156 | if return_footprint: 157 | output_footprint[:] = weights 158 | 159 | if broadcasting: 160 | array_out = array_out.reshape(shape_out) 161 | if return_footprint: 162 | output_footprint = output_footprint.reshape(shape_out) 163 | 164 | if return_footprint: 165 | return array_out, output_footprint 166 | else: 167 | return array_out 168 | -------------------------------------------------------------------------------- /reproject/spherical_intersect/high_level.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | 3 | from ..common import _reproject_dispatcher 4 | from ..utils import parse_input_data, parse_output_projection 5 | from ..wcs_utils import has_celestial 6 | from .core import _reproject_celestial 7 | 8 | __all__ = ["reproject_exact"] 9 | 10 | 11 | def reproject_exact( 12 | input_data, 13 | output_projection, 14 | shape_out=None, 15 | hdu_in=0, 16 | output_array=None, 17 | output_footprint=None, 18 | return_footprint=True, 19 | block_size=None, 20 | parallel=False, 21 | return_type=None, 22 | ): 23 | """ 24 | Reproject data to a new projection using flux-conserving spherical 25 | polygon intersection (this is the slowest algorithm). 26 | 27 | Parameters 28 | ---------- 29 | input_data : object 30 | The input data to reproject. This can be: 31 | 32 | * The name of a FITS file as a `str` or a `pathlib.Path` object 33 | * An `~astropy.io.fits.HDUList` object 34 | * An image HDU object such as a `~astropy.io.fits.PrimaryHDU`, 35 | `~astropy.io.fits.ImageHDU`, or `~astropy.io.fits.CompImageHDU` 36 | instance 37 | * A tuple where the first element is a `~numpy.ndarray` and the 38 | second element is either a 39 | `~astropy.wcs.wcsapi.BaseLowLevelWCS`, 40 | `~astropy.wcs.wcsapi.BaseHighLevelWCS`, or a 41 | `~astropy.io.fits.Header` object 42 | * An `~astropy.nddata.NDData` object from which the ``.data`` and 43 | ``.wcs`` attributes will be used as the input data. 44 | 45 | If the data array contains more dimensions than are described by the 46 | input header or WCS, the extra dimensions (assumed to be the first 47 | dimensions) are taken to represent multiple images with the same 48 | coordinate information. The coordinate transformation will be computed 49 | once and then each image will be reprojected, offering a speedup over 50 | reprojecting each image individually. 51 | output_projection : `~astropy.wcs.wcsapi.BaseLowLevelWCS` or `~astropy.wcs.wcsapi.BaseHighLevelWCS` or `~astropy.io.fits.Header` 52 | The output projection, which can be either a 53 | `~astropy.wcs.wcsapi.BaseLowLevelWCS`, 54 | `~astropy.wcs.wcsapi.BaseHighLevelWCS`, or a `~astropy.io.fits.Header` 55 | instance. 56 | shape_out : tuple, optional 57 | If ``output_projection`` is a WCS instance, the shape of the output 58 | data should be specified separately. 59 | hdu_in : int or str, optional 60 | If ``input_data`` is a FITS file or an `~astropy.io.fits.HDUList` 61 | instance, specifies the HDU to use. 62 | output_array : None or `~numpy.ndarray` 63 | An array in which to store the reprojected data. This can be any numpy 64 | array including a memory map, which may be helpful when dealing with 65 | extremely large files. 66 | output_footprint : `~numpy.ndarray`, optional 67 | An array in which to store the footprint of reprojected data. This can be 68 | any numpy array including a memory map, which may be helpful when dealing with 69 | extremely large files. 70 | return_footprint : bool 71 | Whether to return the footprint in addition to the output array. 72 | block_size : tuple or 'auto', optional 73 | The size of blocks in terms of output array pixels that each block will handle 74 | reprojecting. Extending out from (0,0) coords positively, block sizes 75 | are clamped to output space edges when a block would extend past edge. 76 | Specifying ``'auto'`` means that reprojection will be done in blocks with 77 | the block size automatically determined. If ``block_size`` is not 78 | specified or set to `None`, the reprojection will not be carried out in 79 | blocks. 80 | parallel : bool or int or str, optional 81 | If `True`, the reprojection is carried out in parallel, and if a 82 | positive integer, this specifies the number of threads to use. 83 | The reprojection will be parallelized over output array blocks specified 84 | by ``block_size`` (if the block size is not set, it will be determined 85 | automatically). To use the currently active dask scheduler (e.g. 86 | dask.distributed), set this to ``'current-scheduler'``. 87 | return_type : {'numpy', 'dask'}, optional 88 | Whether to return numpy or dask arrays - defaults to 'numpy'. 89 | 90 | Returns 91 | ------- 92 | array_new : `~numpy.ndarray` 93 | The reprojected array. 94 | footprint : `~numpy.ndarray` 95 | Footprint of the input array in the output array. Values of 0 indicate 96 | no coverage or valid values in the input image, while values of 1 97 | indicate valid values. Intermediate values indicate partial coverage. 98 | """ 99 | 100 | array_in, wcs_in = parse_input_data(input_data, hdu_in=hdu_in) 101 | wcs_out, shape_out = parse_output_projection( 102 | output_projection, shape_in=array_in.shape, shape_out=shape_out 103 | ) 104 | 105 | if has_celestial(wcs_in) and wcs_in.pixel_n_dim == 2 and wcs_in.world_n_dim == 2: 106 | return _reproject_dispatcher( 107 | _reproject_celestial, 108 | array_in=array_in, 109 | wcs_in=wcs_in, 110 | wcs_out=wcs_out, 111 | shape_out=shape_out, 112 | array_out=output_array, 113 | parallel=parallel, 114 | block_size=block_size, 115 | return_footprint=return_footprint, 116 | output_footprint=output_footprint, 117 | return_type=return_type, 118 | ) 119 | else: 120 | raise NotImplementedError( 121 | "Currently only data with a 2-d celestial " 122 | "WCS can be reprojected using flux-conserving algorithm" 123 | ) 124 | -------------------------------------------------------------------------------- /reproject/spherical_intersect/mNaN.h: -------------------------------------------------------------------------------- 1 | #ifndef _BSD_SOURCE 2 | #define _BSD_SOURCE 3 | #endif 4 | 5 | #include 6 | 7 | #if defined(_MSC_VER) 8 | #define mNaN(x) _isnan(x) || !_finite(x) 9 | #else 10 | #define mNaN(x) isnan(x) || !isfinite(x) 11 | #endif 12 | -------------------------------------------------------------------------------- /reproject/spherical_intersect/overlap.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from ._overlap import _compute_overlap 4 | 5 | __all__ = ["compute_overlap"] 6 | 7 | 8 | def compute_overlap(ilon, ilat, olon, olat): 9 | """Compute the overlap between two 'pixels' in spherical coordinates. 10 | 11 | Parameters 12 | ---------- 13 | ilon : np.ndarray with shape (N, 4) 14 | The longitudes (in radians) defining the four corners of the input pixel 15 | ilat : np.ndarray with shape (N, 4) 16 | The latitudes (in radians) defining the four corners of the input pixel 17 | olon : np.ndarray with shape (N, 4) 18 | The longitudes (in radians) defining the four corners of the output pixel 19 | olat : np.ndarray with shape (N, 4) 20 | The latitudes (in radians) defining the four corners of the output pixel 21 | 22 | Returns 23 | ------- 24 | overlap : np.ndarray of length N 25 | Pixel overlap solid angle in steradians 26 | area_ratio : np.ndarray of length N 27 | TODO 28 | """ 29 | ilon = np.ascontiguousarray(ilon, dtype=np.float64) 30 | ilat = np.ascontiguousarray(ilat, dtype=np.float64) 31 | olon = np.ascontiguousarray(olon, dtype=np.float64) 32 | olat = np.ascontiguousarray(olat, dtype=np.float64) 33 | 34 | return _compute_overlap(ilon, ilat, olon, olat) 35 | -------------------------------------------------------------------------------- /reproject/spherical_intersect/overlapArea.h: -------------------------------------------------------------------------------- 1 | #ifndef OVERLAP_AREA 2 | #define OVERLAP_AREA 3 | 4 | double computeOverlap(double *ilon, double *ilat, double *olon, double *olat, 5 | int energyMode, double refArea, double *areaRatio); 6 | 7 | #endif 8 | -------------------------------------------------------------------------------- /reproject/spherical_intersect/overlapAreaPP.c: -------------------------------------------------------------------------------- 1 | /* Methods to compute pixel overlap areas in the plane. 2 | * 3 | * Originally developed in 2003 / 2004 by John Good. 4 | */ 5 | 6 | #include 7 | 8 | double computeOverlapPP(double *ix, double *iy, double minX, double maxX, 9 | double minY, double maxY, double pixelArea); 10 | 11 | double polyArea(int npts, double *nx, double *ny); 12 | 13 | int rectClip(int n, double *x, double *y, double *nx, double *ny, double minX, 14 | double minY, double maxX, double maxY); 15 | int lineClip(int n, double *x, double *y, double *nx, double *ny, double val, 16 | int dir); 17 | int inPlane(double test, double divider, int direction); 18 | int ptInPoly(double x, double y, int n, double *xp, double *yp); 19 | 20 | // Global variables 21 | double tmpX0[100]; 22 | double tmpX1[100]; 23 | double tmpY0[100]; 24 | double tmpY1[100]; 25 | 26 | /* 27 | * Sets up the polygons, runs the overlap 28 | * computation, and returns the area of overlap. 29 | * This version works in pixel space rather than 30 | * on the celestial sphere. 31 | */ 32 | double computeOverlapPP(double *ix, double *iy, double minX, double maxX, 33 | double minY, double maxY, double pixelArea) { 34 | int npts; 35 | double area; 36 | 37 | double nx[100]; 38 | double ny[100]; 39 | 40 | double xp[4], yp[4]; 41 | 42 | // Clip the input pixel polygon with the output pixel range 43 | 44 | npts = rectClip(4, ix, iy, nx, ny, minX, minY, maxX, maxY); 45 | 46 | // If no points, it may mean that the output is 47 | // completely contained in the input 48 | 49 | if (npts < 3) { 50 | xp[0] = minX; 51 | yp[0] = minY; 52 | xp[1] = maxX; 53 | yp[1] = minY; 54 | xp[2] = maxX; 55 | yp[2] = maxY; 56 | xp[3] = minX; 57 | yp[3] = maxY; 58 | 59 | if (ptInPoly(ix[0], iy[0], 4, xp, yp)) { 60 | area = pixelArea; 61 | return area; 62 | } 63 | 64 | return 0.; 65 | } 66 | 67 | area = polyArea(npts, nx, ny) * pixelArea; 68 | 69 | return area; 70 | } 71 | 72 | int rectClip(int n, double *x, double *y, double *nx, double *ny, double minX, 73 | double minY, double maxX, double maxY) { 74 | int nCurr; 75 | 76 | nCurr = lineClip(n, x, y, tmpX0, tmpY0, minX, 1); 77 | 78 | if (nCurr > 0) { 79 | nCurr = lineClip(nCurr, tmpX0, tmpY0, tmpX1, tmpY1, maxX, 0); 80 | 81 | if (nCurr > 0) { 82 | nCurr = lineClip(nCurr, tmpY1, tmpX1, tmpY0, tmpX0, minY, 1); 83 | 84 | if (nCurr > 0) { 85 | nCurr = lineClip(nCurr, tmpY0, tmpX0, ny, nx, maxY, 0); 86 | } 87 | } 88 | } 89 | 90 | return nCurr; 91 | } 92 | 93 | int lineClip(int n, double *x, double *y, double *nx, double *ny, double val, 94 | int dir) { 95 | int i; 96 | int nout; 97 | int last; 98 | 99 | double ycross; 100 | 101 | nout = 0; 102 | last = inPlane(x[n - 1], val, dir); 103 | 104 | for (i = 0; i < n; ++i) { 105 | if (last) { 106 | if (inPlane(x[i], val, dir)) { 107 | // Both endpoints in, just add the new point 108 | 109 | nx[nout] = x[i]; 110 | ny[nout] = y[i]; 111 | 112 | ++nout; 113 | } else { 114 | // Moved out of the clip region, add the point we moved out 115 | 116 | if (i == 0) 117 | ycross = y[n - 1] 118 | + (y[0] - y[n - 1]) * (val - x[n - 1]) / (x[0] - x[n - 1]); 119 | else 120 | ycross = y[i - 1] 121 | + (y[i] - y[i - 1]) * (val - x[i - 1]) / (x[i] - x[i - 1]); 122 | 123 | nx[nout] = val; 124 | ny[nout] = ycross; 125 | 126 | ++nout; 127 | 128 | last = 0; 129 | } 130 | } else { 131 | if (inPlane(x[i], val, dir)) { 132 | // Moved into the clip region. 133 | // Add the point we moved in, and the end point. 134 | 135 | if (i == 0) 136 | ycross = y[n - 1] 137 | + (y[0] - y[n - 1]) * (val - x[n - 1]) / (x[i] - x[n - 1]); 138 | else 139 | ycross = y[i - 1] 140 | + (y[i] - y[i - 1]) * (val - x[i - 1]) / (x[i] - x[i - 1]); 141 | 142 | nx[nout] = val; 143 | ny[nout] = ycross; 144 | 145 | ++nout; 146 | 147 | nx[nout] = x[i]; 148 | ny[nout] = y[i]; 149 | 150 | ++nout; 151 | 152 | last = 1; 153 | } else { 154 | // Segment entirely clipped. 155 | } 156 | } 157 | } 158 | 159 | return nout; 160 | } 161 | 162 | int inPlane(double test, double divider, int direction) { 163 | if (direction) 164 | return test >= divider; 165 | else 166 | return test <= divider; 167 | } 168 | 169 | double polyArea(int npts, double *nx, double *ny) { 170 | int i, inext; 171 | double area; 172 | 173 | area = 0.; 174 | 175 | for (i = 0; i < npts; ++i) { 176 | inext = (i + 1) % npts; 177 | 178 | area += nx[i] * ny[inext] - nx[inext] * ny[i]; 179 | } 180 | 181 | area = fabs(area) / 2; 182 | 183 | return area; 184 | } 185 | 186 | int ptInPoly(double x, double y, int n, double *xp, double *yp) { 187 | int i, inext, count; 188 | double t; 189 | 190 | count = 0; 191 | 192 | for (i = 0; i < n; ++i) { 193 | inext = (i + 1) % n; 194 | 195 | if (((yp[i] <= y) && (yp[inext] > y)) 196 | || ((yp[i] > y) && (yp[inext] <= y))) { 197 | t = (y - yp[i]) / (yp[inext] - yp[i]); 198 | 199 | if (x < xp[i] + t * (xp[inext] - xp[i])) 200 | ++count; 201 | } 202 | } 203 | 204 | return (count & 1); 205 | } 206 | -------------------------------------------------------------------------------- /reproject/spherical_intersect/reproject_slice_c.c: -------------------------------------------------------------------------------- 1 | #include "overlapArea.h" 2 | #include "reproject_slice_c.h" 3 | 4 | #if defined(_MSC_VER) 5 | #define INLINE _inline 6 | #else 7 | #define INLINE inline 8 | #endif 9 | 10 | static INLINE double min_4(const double *ptr) 11 | { 12 | double retval = ptr[0]; 13 | int i; 14 | for (i = 1; i < 4; ++i) { 15 | if (ptr[i] < retval) { 16 | retval = ptr[i]; 17 | } 18 | } 19 | return retval; 20 | } 21 | 22 | static INLINE double max_4(const double *ptr) 23 | { 24 | double retval = ptr[0]; 25 | int i; 26 | for (i = 1; i < 4; ++i) { 27 | if (ptr[i] > retval) { 28 | retval = ptr[i]; 29 | } 30 | } 31 | return retval; 32 | } 33 | 34 | static INLINE double to_rad(double x) 35 | { 36 | return x * 0.017453292519943295; 37 | } 38 | 39 | // Kernel for overlap computation. 40 | static INLINE void _compute_overlap(double *overlap, 41 | double *area_ratio, 42 | double *ilon, 43 | double *ilat, 44 | double *olon, 45 | double *olat) 46 | { 47 | overlap[0] = computeOverlap(ilon,ilat,olon,olat,0,1,area_ratio); 48 | } 49 | 50 | #define GETPTR2(x,ncols,i,j) (x + (i) * (ncols) + (j)) 51 | #define GETPTRILON(x,i,j) (x + (j)) 52 | 53 | void _reproject_slice_c(int startx, int endx, int starty, int endy, int nx_out, int ny_out, 54 | double *xp_inout, double *yp_inout, double *xw_in, double *yw_in, double *xw_out, double *yw_out, 55 | double *array, double *array_new, double *weights, 56 | int col_in, int col_out, int col_array, int col_new) 57 | { 58 | int i, j, ii, jj, xmin, xmax, ymin, ymax; 59 | double ilon[4], ilat[4], olon[4], olat[4], minmax_x[4], minmax_y[4]; 60 | double overlap, area_ratio, original; 61 | 62 | // Main loop. 63 | for (i = startx; i < endx; ++i) { 64 | for (j = starty; j < endy; ++j) { 65 | // For every input pixel we find the position in the output image in 66 | // pixel coordinates, then use the full range of overlapping output 67 | // pixels with the exact overlap function. 68 | 69 | minmax_x[0] = *GETPTR2(xp_inout,col_in,j,i); 70 | minmax_x[1] = *GETPTR2(xp_inout,col_in,j,i + 1); 71 | minmax_x[2] = *GETPTR2(xp_inout,col_in,j + 1,i + 1); 72 | minmax_x[3] = *GETPTR2(xp_inout,col_in,j + 1,i); 73 | 74 | minmax_y[0] = *GETPTR2(yp_inout,col_in,j,i); 75 | minmax_y[1] = *GETPTR2(yp_inout,col_in,j,i + 1); 76 | minmax_y[2] = *GETPTR2(yp_inout,col_in,j + 1,i + 1); 77 | minmax_y[3] = *GETPTR2(yp_inout,col_in,j + 1,i); 78 | 79 | xmin = (int)(min_4(minmax_x) + .5); 80 | xmax = (int)(max_4(minmax_x) + .5); 81 | ymin = (int)(min_4(minmax_y) + .5); 82 | ymax = (int)(max_4(minmax_y) + .5); 83 | 84 | // Fill in ilon/ilat. 85 | ilon[0] = to_rad(*GETPTR2(xw_in,col_in,j+1,i)); 86 | ilon[1] = to_rad(*GETPTR2(xw_in,col_in,j+1,i+1)); 87 | ilon[2] = to_rad(*GETPTR2(xw_in,col_in,j,i+1)); 88 | ilon[3] = to_rad(*GETPTR2(xw_in,col_in,j,i)); 89 | 90 | ilat[0] = to_rad(*GETPTR2(yw_in,col_in,j+1,i)); 91 | ilat[1] = to_rad(*GETPTR2(yw_in,col_in,j+1,i+1)); 92 | ilat[2] = to_rad(*GETPTR2(yw_in,col_in,j,i+1)); 93 | ilat[3] = to_rad(*GETPTR2(yw_in,col_in,j,i)); 94 | 95 | xmin = xmin > 0 ? xmin : 0; 96 | xmax = (nx_out-1) < xmax ? (nx_out-1) : xmax; 97 | ymin = ymin > 0 ? ymin : 0; 98 | ymax = (ny_out-1) < ymax ? (ny_out-1) : ymax; 99 | 100 | for (ii = xmin; ii < xmax + 1; ++ii) { 101 | for (jj = ymin; jj < ymax + 1; ++jj) { 102 | // Fill out olon/olat. 103 | olon[0] = to_rad(*GETPTR2(xw_out,col_out,jj+1,ii)); 104 | olon[1] = to_rad(*GETPTR2(xw_out,col_out,jj+1,ii+1)); 105 | olon[2] = to_rad(*GETPTR2(xw_out,col_out,jj,ii+1)); 106 | olon[3] = to_rad(*GETPTR2(xw_out,col_out,jj,ii)); 107 | 108 | olat[0] = to_rad(*GETPTR2(yw_out,col_out,jj+1,ii)); 109 | olat[1] = to_rad(*GETPTR2(yw_out,col_out,jj+1,ii+1)); 110 | olat[2] = to_rad(*GETPTR2(yw_out,col_out,jj,ii+1)); 111 | olat[3] = to_rad(*GETPTR2(yw_out,col_out,jj,ii)); 112 | 113 | // Compute the overlap. 114 | _compute_overlap(&overlap,&area_ratio,ilon,ilat,olon,olat); 115 | _compute_overlap(&original,&area_ratio,olon,olat,olon,olat); 116 | 117 | // Write into array_new and weights. 118 | *GETPTR2(array_new,col_new,jj,ii) += *GETPTR2(array,col_array,j,i) * 119 | (overlap / original); 120 | 121 | *GETPTR2(weights,col_new,jj,ii) += (overlap / original); 122 | } 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /reproject/spherical_intersect/reproject_slice_c.h: -------------------------------------------------------------------------------- 1 | #ifndef REPROJECT_SLICE_C_H 2 | #define REPROJECT_SLICE_C_H 3 | 4 | void _reproject_slice_c(int startx, int endx, int starty, int endy, int nx_out, int ny_out, 5 | double *xp_inout, double *yp_inout, double *xw_in, double *yw_in, double *xw_out, double *yw_out, 6 | double *array, double *array_new, double *weights, 7 | int col_in, int col_out, int col_array, int col_new); 8 | 9 | #endif 10 | -------------------------------------------------------------------------------- /reproject/spherical_intersect/setup_package.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import numpy as np 4 | from setuptools import Extension 5 | 6 | REPROJECT_ROOT = os.path.relpath(os.path.dirname(__file__)) 7 | 8 | 9 | def get_extensions(): 10 | libraries = [] 11 | 12 | sources = [] 13 | sources.append(os.path.join(REPROJECT_ROOT, "_overlap.pyx")) 14 | sources.append(os.path.join(REPROJECT_ROOT, "overlapArea.c")) 15 | sources.append(os.path.join(REPROJECT_ROOT, "reproject_slice_c.c")) 16 | 17 | include_dirs = [np.get_include()] 18 | include_dirs.append(REPROJECT_ROOT) 19 | 20 | # Note that to set the DEBUG variable in the overlapArea.c code, which 21 | # results in debugging information being printed out, you can set 22 | # DEBUG_OVERLAP_AREA=1 at build-time. 23 | if int(os.environ.get("DEBUG_OVERLAP_AREA", 0)): 24 | define_macros = [("DEBUG_OVERLAP_AREA", 1)] 25 | else: 26 | define_macros = None 27 | 28 | extension = Extension( 29 | name="reproject.spherical_intersect._overlap", 30 | sources=sources, 31 | include_dirs=include_dirs, 32 | libraries=libraries, 33 | language="c", 34 | extra_compile_args=["-O2"], 35 | define_macros=define_macros, 36 | ) 37 | 38 | return [extension] 39 | -------------------------------------------------------------------------------- /reproject/spherical_intersect/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/spherical_intersect/tests/__init__.py -------------------------------------------------------------------------------- /reproject/spherical_intersect/tests/test_high_level.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | 3 | import warnings 4 | 5 | import numpy as np 6 | import pytest 7 | from astropy.io import fits 8 | from astropy.utils.data import get_pkg_data_filename 9 | from astropy.wcs import WCS 10 | from numpy.testing import assert_allclose 11 | 12 | from ..high_level import reproject_exact 13 | 14 | 15 | class TestReprojectExact: 16 | def setup_class(self): 17 | header_gal = get_pkg_data_filename("../../tests/data/gc_ga.hdr") 18 | header_equ = get_pkg_data_filename("../../tests/data/gc_eq.hdr") 19 | self.header_in = fits.Header.fromtextfile(header_gal) 20 | self.header_out = fits.Header.fromtextfile(header_equ) 21 | 22 | self.header_out["NAXIS"] = 2 23 | self.header_out["NAXIS1"] = 600 24 | self.header_out["NAXIS2"] = 550 25 | 26 | self.array_in = np.ones((100, 100)) 27 | 28 | self.wcs_in = WCS(self.header_in) 29 | self.wcs_out = WCS(self.header_out) 30 | 31 | def test_array_wcs(self): 32 | reproject_exact((self.array_in, self.wcs_in), self.wcs_out, shape_out=(200, 200)) 33 | 34 | def test_array_header(self): 35 | reproject_exact((self.array_in, self.header_in), self.header_out) 36 | 37 | def test_parallel_option(self): 38 | reproject_exact((self.array_in, self.header_in), self.header_out, parallel=1) 39 | 40 | with pytest.raises(ValueError) as exc: 41 | reproject_exact((self.array_in, self.header_in), self.header_out, parallel=-1) 42 | assert exc.value.args[0] == "The number of processors to use must be strictly positive" 43 | 44 | def test_reproject_parallel_consistency(self): 45 | reproject_exact((self.array_in, self.header_in), self.header_out, parallel=1) 46 | 47 | array1, footprint1 = reproject_exact( 48 | (self.array_in, self.header_in), self.header_out, parallel=False 49 | ) 50 | array2, footprint2 = reproject_exact( 51 | (self.array_in, self.header_in), self.header_out, parallel=4 52 | ) 53 | 54 | np.testing.assert_allclose(array1, array2, rtol=1.0e-5) 55 | 56 | np.testing.assert_allclose(footprint1, footprint2, rtol=3.0e-5) 57 | 58 | 59 | def test_identity(): 60 | # Reproject an array and WCS to itself 61 | 62 | wcs = WCS(naxis=2) 63 | wcs.wcs.ctype = "RA---TAN", "DEC--TAN" 64 | wcs.wcs.crpix = 322, 151 65 | wcs.wcs.crval = 43, 23 66 | wcs.wcs.cdelt = -0.1, 0.1 67 | wcs.wcs.equinox = 2000.0 68 | 69 | np.random.seed(1249) 70 | 71 | array_in = np.random.random((423, 344)) 72 | array_out, footprint = reproject_exact((array_in, wcs), wcs, shape_out=array_in.shape) 73 | 74 | assert_allclose(array_out, array_in, atol=1e-10) 75 | 76 | 77 | def test_reproject_precision_warning(): 78 | for res in [0.1 / 3600, 0.01 / 3600]: 79 | wcs1 = WCS() 80 | wcs1.wcs.ctype = "RA---TAN", "DEC--TAN" 81 | wcs1.wcs.crval = 13, 80 82 | wcs1.wcs.crpix = 10.0, 10.0 83 | wcs1.wcs.cdelt = res, res 84 | 85 | wcs2 = WCS() 86 | wcs2.wcs.ctype = "RA---TAN", "DEC--TAN" 87 | wcs2.wcs.crval = 13, 80 88 | wcs2.wcs.crpix = 3, 3 89 | wcs2.wcs.cdelt = 3 * res, 3 * res 90 | 91 | array = np.zeros((19, 19)) 92 | array[9, 9] = 1 93 | 94 | if res < 0.05 / 3600: 95 | with pytest.warns( 96 | UserWarning, match="The reproject_exact function currently has precision" 97 | ): 98 | reproject_exact((array, wcs1), wcs2, shape_out=(5, 5)) 99 | else: 100 | with warnings.catch_warnings(record=True) as w: 101 | reproject_exact((array, wcs1), wcs2, shape_out=(5, 5)) 102 | assert len(w) == 0 103 | 104 | 105 | def _setup_for_broadcast_test(): 106 | with fits.open(get_pkg_data_filename("data/galactic_2d.fits", package="reproject.tests")) as pf: 107 | hdu_in = pf[0] 108 | header_in = hdu_in.header.copy() 109 | header_out = hdu_in.header.copy() 110 | header_out["CTYPE1"] = "RA---TAN" 111 | header_out["CTYPE2"] = "DEC--TAN" 112 | header_out["CRVAL1"] = 266.39311 113 | header_out["CRVAL2"] = -28.939779 114 | 115 | data = hdu_in.data 116 | 117 | image_stack = np.stack((data, data.T, data[::-1], data[:, ::-1])) 118 | 119 | # Build the reference array through un-broadcast reprojections 120 | array_ref = [] 121 | footprint_ref = [] 122 | for i in range(len(image_stack)): 123 | array_out, footprint_out = reproject_exact((image_stack[i], header_in), header_out) 124 | array_ref.append(array_out) 125 | footprint_ref.append(footprint_out) 126 | array_ref = np.stack(array_ref) 127 | footprint_ref = np.stack(footprint_ref) 128 | 129 | return image_stack, array_ref, footprint_ref, header_in, header_out 130 | 131 | 132 | @pytest.mark.parametrize("input_extra_dims", (1, 2)) 133 | @pytest.mark.parametrize("output_shape", (None, "single", "full")) 134 | @pytest.mark.parametrize("input_as_wcs", (True, False)) 135 | @pytest.mark.parametrize("output_as_wcs", (True, False)) 136 | def test_broadcast_reprojection(input_extra_dims, output_shape, input_as_wcs, output_as_wcs): 137 | image_stack, array_ref, footprint_ref, header_in, header_out = _setup_for_broadcast_test() 138 | # Test both single and multiple dimensions being broadcast 139 | if input_extra_dims == 2: 140 | image_stack = image_stack.reshape((2, 2, *image_stack.shape[-2:])) 141 | array_ref.shape = image_stack.shape 142 | footprint_ref.shape = image_stack.shape 143 | 144 | # Test different ways of providing the output shape 145 | if output_shape == "single": 146 | # Have the broadcast dimensions be auto-added to the output shape 147 | output_shape = image_stack.shape[-2:] 148 | elif output_shape == "full": 149 | # Provide the broadcast dimensions as part of the output shape 150 | output_shape = image_stack.shape 151 | 152 | # Ensure logic works with WCS inputs as well as Header inputs 153 | if input_as_wcs: 154 | header_in = WCS(header_in) 155 | if output_as_wcs: 156 | header_out = WCS(header_out) 157 | if output_shape is None: 158 | # This combination of parameter values is not valid 159 | return 160 | 161 | array_broadcast, footprint_broadcast = reproject_exact( 162 | (image_stack, header_in), header_out, output_shape 163 | ) 164 | 165 | np.testing.assert_allclose(footprint_broadcast, footprint_ref) 166 | np.testing.assert_allclose(array_broadcast, array_ref) 167 | 168 | 169 | @pytest.mark.parametrize("input_extra_dims", (1, 2)) 170 | @pytest.mark.parametrize("output_shape", (None, "single", "full")) 171 | @pytest.mark.parametrize("parallel", (2, False)) 172 | def test_broadcast_parallel_reprojection(input_extra_dims, output_shape, parallel): 173 | image_stack, array_ref, footprint_ref, header_in, header_out = _setup_for_broadcast_test() 174 | # Test both single and multiple dimensions being broadcast 175 | if input_extra_dims == 2: 176 | image_stack = image_stack.reshape((2, 2, *image_stack.shape[-2:])) 177 | array_ref.shape = image_stack.shape 178 | footprint_ref.shape = image_stack.shape 179 | 180 | # Test different ways of providing the output shape 181 | if output_shape == "single": 182 | # Have the broadcast dimensions be auto-added to the output shape 183 | output_shape = image_stack.shape[-2:] 184 | elif output_shape == "full": 185 | # Provide the broadcast dimensions as part of the output shape 186 | output_shape = image_stack.shape 187 | 188 | array_broadcast, footprint_broadcast = reproject_exact( 189 | (image_stack, header_in), header_out, output_shape, parallel=parallel 190 | ) 191 | 192 | np.testing.assert_allclose(footprint_broadcast, footprint_ref) 193 | np.testing.assert_allclose(array_broadcast, array_ref) 194 | 195 | 196 | def test_exact_input_output_types(valid_celestial_input_data, valid_celestial_output_projections): 197 | # Check that all valid input/output types work properly 198 | 199 | array_ref, wcs_in_ref, input_value, kwargs_in = valid_celestial_input_data 200 | 201 | wcs_out_ref, shape_ref, output_value, kwargs_out = valid_celestial_output_projections 202 | 203 | # Compute reference 204 | 205 | output_ref, footprint_ref = reproject_exact( 206 | (array_ref, wcs_in_ref), wcs_out_ref, shape_out=shape_ref 207 | ) 208 | 209 | # Compute test 210 | 211 | output_test, footprint_test = reproject_exact( 212 | input_value, output_value, **kwargs_in, **kwargs_out 213 | ) 214 | 215 | assert_allclose(output_ref, output_test) 216 | assert_allclose(footprint_ref, footprint_test) 217 | -------------------------------------------------------------------------------- /reproject/spherical_intersect/tests/test_overlap.py: -------------------------------------------------------------------------------- 1 | from itertools import product 2 | 3 | import numpy as np 4 | import pytest 5 | 6 | from ..overlap import compute_overlap 7 | 8 | 9 | def test_full_overlap(): 10 | EPS = np.radians(1e-2) 11 | lon, lat = np.array([[0, EPS, EPS, 0]]), np.array([[0, 0, EPS, EPS]]) 12 | overlap, area_ratio = compute_overlap(lon, lat, lon, lat) 13 | np.testing.assert_allclose(overlap, EPS**2, rtol=1e-6) 14 | np.testing.assert_allclose(area_ratio, 1, rtol=1e-6) 15 | 16 | 17 | def test_partial_overlap(): 18 | EPS = np.radians(1e-2) 19 | ilon = np.array([[0, EPS, EPS, 0]]) 20 | ilat = np.array([[0, 0, EPS, EPS]]) 21 | olon = np.array([[0.5 * EPS, 1.5 * EPS, 1.5 * EPS, 0.5 * EPS]]) 22 | olat = np.array([[0, 0, EPS, EPS]]) 23 | 24 | overlap, area_ratio = compute_overlap(ilon, ilat, olon, olat) 25 | np.testing.assert_allclose(overlap, 0.5 * EPS**2, rtol=1e-6) 26 | np.testing.assert_allclose(area_ratio, 1, rtol=1e-6) 27 | 28 | 29 | @pytest.mark.parametrize(("clockwise1", "clockwise2"), product([False, True], [False, True])) 30 | def test_overlap_direction(clockwise1, clockwise2): 31 | # Regression test for a bug that caused the calculation to fail if one or 32 | # both of the polygons were clockwise 33 | 34 | EPS = np.radians(1e-2) 35 | ilon = np.array([[0, EPS, EPS, 0]]) 36 | ilat = np.array([[0, 0, EPS, EPS]]) 37 | olon = np.array([[0.5 * EPS, 1.5 * EPS, 1.5 * EPS, 0.5 * EPS]]) 38 | olat = np.array([[0, 0, EPS, EPS]]) 39 | 40 | if clockwise1: 41 | ilon, ilat = ilon[:, ::-1], ilat[:, ::-1] 42 | 43 | if clockwise2: 44 | olon, olat = olon[:, ::-1], olat[:, ::-1] 45 | 46 | overlap, area_ratio = compute_overlap(ilon, ilat, olon, olat) 47 | np.testing.assert_allclose(overlap, 0.5 * EPS**2, rtol=1e-6) 48 | np.testing.assert_allclose(area_ratio, 1, rtol=1e-6) 49 | -------------------------------------------------------------------------------- /reproject/spherical_intersect/tests/test_reproject.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | 3 | import numpy as np 4 | import pytest 5 | from astropy.io import fits 6 | from astropy.utils.data import get_pkg_data_filename 7 | from astropy.wcs import WCS 8 | 9 | from ...interpolation.tests.test_core import as_high_level_wcs 10 | from ..core import _reproject_celestial 11 | 12 | 13 | def test_reproject_celestial_slices_2d(): 14 | header_in = fits.Header.fromtextfile(get_pkg_data_filename("../../tests/data/gc_ga.hdr")) 15 | header_out = fits.Header.fromtextfile(get_pkg_data_filename("../../tests/data/gc_eq.hdr")) 16 | 17 | array_in = np.ones((100, 100)) 18 | 19 | wcs_in = WCS(header_in) 20 | wcs_out = WCS(header_out) 21 | 22 | _reproject_celestial(array_in, wcs_in, wcs_out, (200, 200)) 23 | 24 | 25 | DATA = np.array([[1, 2], [3, 4]], dtype=np.int64) 26 | 27 | INPUT_HDR = """ 28 | WCSAXES = 2 / Number of coordinate axes 29 | CRPIX1 = 299.628 / Pixel coordinate of reference point 30 | CRPIX2 = 299.394 / Pixel coordinate of reference point 31 | CDELT1 = -0.001666666 / [deg] Coordinate increment at reference point 32 | CDELT2 = 0.001666666 / [deg] Coordinate increment at reference point 33 | CUNIT1 = 'deg' / Units of coordinate increment and value 34 | CUNIT2 = 'deg' / Units of coordinate increment and value 35 | CTYPE1 = 'GLON-CAR' / galactic longitude, plate caree projection 36 | CTYPE2 = 'GLAT-CAR' / galactic latitude, plate caree projection 37 | CRVAL1 = 0.0 / [deg] Coordinate value at reference point 38 | CRVAL2 = 0.0 / [deg] Coordinate value at reference point 39 | LONPOLE = 0.0 / [deg] Native longitude of celestial pole 40 | LATPOLE = 90.0 / [deg] Native latitude of celestial pole 41 | """ 42 | 43 | OUTPUT_HDR = """ 44 | WCSAXES = 2 / Number of coordinate axes 45 | CRPIX1 = 2.5 / Pixel coordinate of reference point 46 | CRPIX2 = 2.5 / Pixel coordinate of reference point 47 | CDELT1 = -0.001500000 / [deg] Coordinate increment at reference point 48 | CDELT2 = 0.001500000 / [deg] Coordinate increment at reference point 49 | CUNIT1 = 'deg' / Units of coordinate increment and value 50 | CUNIT2 = 'deg' / Units of coordinate increment and value 51 | CTYPE1 = 'RA---TAN' / Right ascension, gnomonic projection 52 | CTYPE2 = 'DEC--TAN' / Declination, gnomonic projection 53 | CRVAL1 = 267.183880241 / [deg] Coordinate value at reference point 54 | CRVAL2 = -28.768527143 / [deg] Coordinate value at reference point 55 | LONPOLE = 180.0 / [deg] Native longitude of celestial pole 56 | LATPOLE = -28.768527143 / [deg] Native latitude of celestial pole 57 | EQUINOX = 2000.0 / [yr] Equinox of equatorial coordinates 58 | """ 59 | 60 | MONTAGE_REF = np.array( 61 | [ 62 | [np.nan, 2.0, 2.0, np.nan], 63 | [1.0, 1.6768244, 3.35364754, 4.0], 64 | [1.0, 1.6461656, 3.32308315, 4.0], 65 | [np.nan, 3.0, 3.0, np.nan], 66 | ] 67 | ) 68 | 69 | 70 | @pytest.mark.parametrize("wcsapi", (False, True)) 71 | def test_reproject_celestial_montage(wcsapi): 72 | # Accuracy compared to Montage 73 | 74 | wcs_in = WCS(fits.Header.fromstring(INPUT_HDR, sep="\n")) 75 | wcs_out = WCS(fits.Header.fromstring(OUTPUT_HDR, sep="\n")) 76 | 77 | if wcsapi: # Enforce a pure wcsapi API 78 | wcs_in, wcs_out = as_high_level_wcs(wcs_in), as_high_level_wcs(wcs_out) 79 | 80 | array, footprint = _reproject_celestial(DATA, wcs_in, wcs_out, (4, 4)) 81 | 82 | # TODO: improve agreement with Montage - at the moment agreement is ~10% 83 | np.testing.assert_allclose(array, MONTAGE_REF, rtol=0.09) 84 | 85 | 86 | def test_reproject_flipping(): 87 | # Regression test for a bug that caused issues when the WCS was oriented 88 | # in a way that meant polygon vertices were clockwise. 89 | 90 | wcs_in = WCS(fits.Header.fromstring(INPUT_HDR, sep="\n")) 91 | wcs_out = WCS(fits.Header.fromstring(OUTPUT_HDR, sep="\n")) 92 | array1, footprint1 = _reproject_celestial(DATA, wcs_in, wcs_out, (4, 4)) 93 | 94 | # Repeat with an input that is flipped horizontally with the equivalent WCS 95 | wcs_in_flipped = WCS(fits.Header.fromstring(INPUT_HDR, sep="\n")) 96 | wcs_in_flipped.wcs.cdelt[0] = -wcs_in_flipped.wcs.cdelt[0] 97 | wcs_in_flipped.wcs.crpix[0] = 3 - wcs_in_flipped.wcs.crpix[0] 98 | array2, footprint2 = _reproject_celestial(DATA[:, ::-1], wcs_in_flipped, wcs_out, (4, 4)) 99 | 100 | # Repeat with an output that is flipped horizontally with the equivalent WCS 101 | wcs_out_flipped = WCS(fits.Header.fromstring(OUTPUT_HDR, sep="\n")) 102 | wcs_out_flipped.wcs.cdelt[0] = -wcs_out_flipped.wcs.cdelt[0] 103 | wcs_out_flipped.wcs.crpix[0] = 5 - wcs_out_flipped.wcs.crpix[0] 104 | array3, footprint3 = _reproject_celestial(DATA, wcs_in, wcs_out_flipped, (4, 4)) 105 | array3, footprint3 = array3[:, ::-1], footprint3[:, ::-1] 106 | 107 | np.testing.assert_allclose(array1, array2, rtol=1.0e-5) 108 | np.testing.assert_allclose(array1, array3, rtol=1.0e-5) 109 | 110 | np.testing.assert_allclose(footprint1, footprint2, rtol=3.0e-5) 111 | np.testing.assert_allclose(footprint1, footprint3, rtol=3.0e-5) 112 | -------------------------------------------------------------------------------- /reproject/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/tests/__init__.py -------------------------------------------------------------------------------- /reproject/tests/data/aia_171_level1.asdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/tests/data/aia_171_level1.asdf -------------------------------------------------------------------------------- /reproject/tests/data/aia_171_level1.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/tests/data/aia_171_level1.fits -------------------------------------------------------------------------------- /reproject/tests/data/cube.hdr: -------------------------------------------------------------------------------- 1 | WCSAXES = 3 / Number of coordinate axes 2 | CRPIX1 = -799.0 / Pixel coordinate of reference point 3 | CRPIX2 = -4741.913 / Pixel coordinate of reference point 4 | CRPIX3 = -187.0 / Pixel coordinate of reference point 5 | CDELT1 = -0.006388889 / [deg] Coordinate increment at reference point 6 | CDELT2 = 0.006388889 / [deg] Coordinate increment at reference point 7 | CDELT3 = 66.42361 / [m s-1] Coordinate increment at reference point 8 | CUNIT1 = 'deg' / Units of coordinate increment and value 9 | CUNIT2 = 'deg' / Units of coordinate increment and value 10 | CUNIT3 = 'm s-1' / Units of coordinate increment and value 11 | CTYPE1 = 'RA---SFL' / Right ascension, Sanson-Flamsteed projection 12 | CTYPE2 = 'DEC--SFL' / Declination, Sanson-Flamsteed projection 13 | CTYPE3 = 'VOPT' / Optical velocity (linear) 14 | CRVAL1 = 57.6599999999 / [deg] Coordinate value at reference point 15 | CRVAL2 = 0.0 / [deg] Coordinate value at reference point 16 | CRVAL3 = -9959.44378305 / [m s-1] Coordinate value at reference point 17 | LONPOLE = 0.0 / [deg] Native longitude of celestial pole 18 | LATPOLE = 90.0 / [deg] Native latitude of celestial pole 19 | EQUINOX = 2000.0 / [yr] Equinox of equatorial coordinates 20 | SPECSYS = 'LSRK' / Reference frame of spectral coordinates 21 | -------------------------------------------------------------------------------- /reproject/tests/data/equatorial_3d.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/tests/data/equatorial_3d.fits -------------------------------------------------------------------------------- /reproject/tests/data/galactic_2d.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/tests/data/galactic_2d.fits -------------------------------------------------------------------------------- /reproject/tests/data/gc_eq.hdr: -------------------------------------------------------------------------------- 1 | WCSAXES = 2 / Number of coordinate axes 2 | CRPIX1 = 361.0 / Pixel coordinate of reference point 3 | CRPIX2 = 360.5 / Pixel coordinate of reference point 4 | CDELT1 = -0.001388889 / [deg] Coordinate increment at reference point 5 | CDELT2 = 0.001388889 / [deg] Coordinate increment at reference point 6 | CUNIT1 = 'deg' / Units of coordinate increment and value 7 | CUNIT2 = 'deg' / Units of coordinate increment and value 8 | CTYPE1 = 'RA---TAN' / Right ascension, gnomonic projection 9 | CTYPE2 = 'DEC--TAN' / Declination, gnomonic projection 10 | CRVAL1 = 266.4 / [deg] Coordinate value at reference point 11 | CRVAL2 = -28.93333 / [deg] Coordinate value at reference point 12 | LONPOLE = 180.0 / [deg] Native longitude of celestial pole 13 | LATPOLE = -28.93333 / [deg] Native latitude of celestial pole 14 | EQUINOX = 2000.0 / [yr] Equinox of equatorial coordinates -------------------------------------------------------------------------------- /reproject/tests/data/gc_ga.hdr: -------------------------------------------------------------------------------- 1 | WCSAXES = 2 / Number of coordinate axes 2 | CRPIX1 = 75.907 / Pixel coordinate of reference point 3 | CRPIX2 = 74.8485 / Pixel coordinate of reference point 4 | CDELT1 = -0.006666666828 / [deg] Coordinate increment at reference point 5 | CDELT2 = 0.006666666828 / [deg] Coordinate increment at reference point 6 | CUNIT1 = 'deg' / Units of coordinate increment and value 7 | CUNIT2 = 'deg' / Units of coordinate increment and value 8 | CTYPE1 = 'GLON-CAR' / galactic longitude, plate caree projection 9 | CTYPE2 = 'GLAT-CAR' / galactic latitude, plate caree projection 10 | CRVAL1 = 0.0 / [deg] Coordinate value at reference point 11 | CRVAL2 = 0.0 / [deg] Coordinate value at reference point 12 | LONPOLE = 0.0 / [deg] Native longitude of celestial pole 13 | LATPOLE = 90.0 / [deg] Native latitude of celestial pole -------------------------------------------------------------------------------- /reproject/tests/data/generate_asdf.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | 3 | import astropy.modeling.models as m 4 | import astropy.units as u 5 | import gwcs.coordinate_frames as cf 6 | from astropy.modeling import CompoundModel, Model 7 | from gwcs import WCS 8 | from numpy.typing import ArrayLike 9 | 10 | 11 | def generate_celestial_transform( 12 | crpix: Iterable[float] | u.Quantity, 13 | cdelt: Iterable[float] | u.Quantity, 14 | pc: ArrayLike | u.Quantity, 15 | crval: Iterable[float] | u.Quantity, 16 | lon_pole: float | u.Quantity = None, 17 | projection: Model = None, 18 | ) -> CompoundModel: 19 | """ 20 | Create a simple celestial transform from FITS like parameters. 21 | 22 | Supports unitful or unitless parameters, but if any parameters have units 23 | all must have units, if parameters are unitless they are assumed to be in 24 | degrees. 25 | 26 | Parameters 27 | ---------- 28 | crpix 29 | The reference pixel (a length two array). 30 | crval 31 | The world coordinate at the reference pixel (a length two array). 32 | cdelt 33 | The sample interval along the pixel axis 34 | pc 35 | The rotation matrix for the affine transform. If specifying parameters 36 | with units this should have celestial (``u.deg``) units. 37 | lon_pole 38 | The longitude of the celestial pole, defaults to 180 degrees. 39 | projection 40 | The map projection to use, defaults to ``TAN``. 41 | 42 | Notes 43 | ----- 44 | 45 | This function has not been tested with more complex projections. Ensure 46 | that your lon_pole is correct for your projection. 47 | """ 48 | projection = m.Pix2Sky_TAN() if projection is None else None 49 | spatial_unit = None 50 | if hasattr(crval[0], "unit"): 51 | spatial_unit = crval[0].unit 52 | # TODO: Note this assumption is only valid for certain projections. 53 | if lon_pole is None: 54 | lon_pole = 180 55 | if spatial_unit is not None: 56 | # Lon pole should always have the units of degrees 57 | lon_pole = u.Quantity(lon_pole, unit=u.deg) 58 | 59 | # Make translation unitful if all parameters have units 60 | translation = (0, 0) 61 | if spatial_unit is not None: 62 | translation *= pc.unit 63 | # If we have units then we need to convert all things to Quantity 64 | # as they might be Parameter classes 65 | crpix = u.Quantity(crpix) 66 | cdelt = u.Quantity(cdelt) 67 | crval = u.Quantity(crval) 68 | lon_pole = u.Quantity(lon_pole) 69 | pc = u.Quantity(pc) 70 | 71 | shift = m.Shift(-crpix[0]) & m.Shift(-crpix[1]) 72 | scale = m.Multiply(cdelt[0]) & m.Multiply(cdelt[1]) 73 | rot = m.AffineTransformation2D(pc, translation=translation) 74 | skyrot = m.RotateNative2Celestial(crval[0], crval[1], lon_pole) 75 | return shift | rot | scale | projection | skyrot 76 | 77 | 78 | def generate_asdf(input_file="aia_171_level1.fits", output_file="aia_171_level1.asdf"): 79 | # Put imports for optional or not dependencies here 80 | import asdf 81 | import sunpy.map 82 | 83 | aia_map = sunpy.map.Map(input_file) 84 | 85 | spatial_unit = aia_map.spatial_units[0] 86 | transform = generate_celestial_transform( 87 | crpix=aia_map.reference_pixel, 88 | cdelt=aia_map.scale, 89 | pc=aia_map.rotation_matrix * u.pix, 90 | crval=aia_map.wcs.wcs.crval * u.deg, 91 | ) 92 | 93 | input_frame = cf.Frame2D() 94 | output_frame = cf.CelestialFrame( 95 | reference_frame=aia_map.coordinate_frame, 96 | unit=(u.arcsec, u.arcsec), 97 | axes_names=("Helioprojective Longitude", "Helioprojective Latitude"), 98 | ) 99 | 100 | aia_gwcs = WCS( 101 | forward_transform=transform, 102 | input_frame=input_frame, 103 | output_frame=output_frame, 104 | ) 105 | 106 | tree = { 107 | "data": aia_map.data, 108 | "meta": dict(aia_map.meta), 109 | "wcs": aia_gwcs, 110 | } 111 | 112 | af = asdf.AsdfFile(tree) 113 | af.write_to(output_file) 114 | 115 | 116 | if __name__ == "__main__": 117 | generate_asdf() 118 | -------------------------------------------------------------------------------- /reproject/tests/data/image_with_distortion_map.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/tests/data/image_with_distortion_map.fits -------------------------------------------------------------------------------- /reproject/tests/data/secchi_l0_a.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/tests/data/secchi_l0_a.fits -------------------------------------------------------------------------------- /reproject/tests/data/secchi_l0_b.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astropy/reproject/9d510cba67f7544522089ef974c8a8c2bfdbdfce/reproject/tests/data/secchi_l0_b.fits -------------------------------------------------------------------------------- /reproject/tests/helpers.py: -------------------------------------------------------------------------------- 1 | from astropy.io import fits 2 | from astropy.wcs import WCS 3 | from numpy.testing import assert_allclose 4 | 5 | from reproject.conftest import TestLowLevelWCS 6 | 7 | 8 | def array_footprint_to_hdulist(array, footprint, header): 9 | hdulist = fits.HDUList() 10 | hdulist.append(fits.PrimaryHDU(array, header)) 11 | hdulist.append(fits.ImageHDU(footprint, header, name="footprint")) 12 | return hdulist 13 | 14 | 15 | def _underlying_wcs(wcs): 16 | # For testing purposes, try and return an underlying WCS object if equivalent 17 | 18 | if hasattr(wcs, "low_level_wcs"): 19 | if isinstance(wcs.low_level_wcs, WCS): 20 | return wcs.low_level_wcs 21 | elif isinstance(wcs.low_level_wcs, TestLowLevelWCS): 22 | return wcs.low_level_wcs._low_level_wcs 23 | elif isinstance(wcs, TestLowLevelWCS): 24 | return wcs._low_level_wcs 25 | 26 | return wcs 27 | 28 | 29 | def assert_wcs_allclose(wcs1, wcs2, **kwargs): 30 | # First check whether the WCSes are actually the same, either directly 31 | # or through layers 32 | 33 | if wcs1 is wcs2: 34 | return True 35 | 36 | if _underlying_wcs(wcs1) is _underlying_wcs(wcs2): 37 | return True 38 | 39 | header1 = wcs1.to_header() 40 | header2 = wcs2.to_header() 41 | 42 | assert sorted(header1) == sorted(header2) 43 | 44 | for key1, value1 in header1.items(): 45 | if isinstance(value1, str): 46 | assert value1 == header2[key1] 47 | else: 48 | assert_allclose(value1, header2[key1], **kwargs) 49 | -------------------------------------------------------------------------------- /reproject/tests/test_array_utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from numpy.testing import assert_allclose 3 | from scipy.ndimage import map_coordinates as scipy_map_coordinates 4 | 5 | from reproject.array_utils import map_coordinates 6 | 7 | 8 | def test_custom_map_coordinates(): 9 | np.random.seed(1249) 10 | 11 | data = np.random.random((3, 4)) 12 | 13 | coords = np.random.uniform(-2, 6, (2, 10000)) 14 | 15 | expected = scipy_map_coordinates( 16 | np.pad(data, 1, mode="edge"), 17 | coords + 1, 18 | order=1, 19 | cval=np.nan, 20 | mode="constant", 21 | ) 22 | 23 | reset = np.zeros(coords.shape[1], dtype=bool) 24 | 25 | for i in range(coords.shape[0]): 26 | reset |= coords[i] < -0.5 27 | reset |= coords[i] > data.shape[i] - 0.5 28 | 29 | expected[reset] = np.nan 30 | 31 | result = map_coordinates( 32 | data, 33 | coords, 34 | order=1, 35 | cval=np.nan, 36 | mode="constant", 37 | ) 38 | 39 | assert_allclose(result, expected) 40 | -------------------------------------------------------------------------------- /reproject/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import dask.array as da 2 | import numpy as np 3 | import pytest 4 | from astropy.io import fits 5 | from astropy.utils.data import get_pkg_data_filename 6 | from astropy.wcs import WCS 7 | 8 | from reproject.conftest import set_wcs_array_shape 9 | from reproject.tests.helpers import assert_wcs_allclose 10 | from reproject.utils import ( 11 | hdu_to_numpy_memmap, 12 | parse_input_data, 13 | parse_input_shape, 14 | parse_output_projection, 15 | ) 16 | from reproject.wcs_utils import has_celestial 17 | 18 | 19 | @pytest.mark.filterwarnings("ignore:unclosed file:ResourceWarning") 20 | def test_parse_input_data(tmpdir, valid_celestial_input_data, request): 21 | array_ref, wcs_ref, input_value, kwargs = valid_celestial_input_data 22 | 23 | data, wcs = parse_input_data(input_value, **kwargs) 24 | assert isinstance(data, da.Array | np.ndarray) 25 | np.testing.assert_allclose(data, array_ref) 26 | assert_wcs_allclose(wcs, wcs_ref) 27 | 28 | 29 | def test_parse_input_data_invalid(): 30 | data = np.ones((30, 40)) 31 | 32 | with pytest.raises(TypeError, match="input_data should either be an HDU object"): 33 | parse_input_data(data) 34 | 35 | 36 | def test_parse_input_data_missing_hdu_in(): 37 | hdulist = fits.HDUList( 38 | [fits.PrimaryHDU(data=np.ones((30, 40))), fits.ImageHDU(data=np.ones((20, 30)))] 39 | ) 40 | 41 | with pytest.raises(ValueError, match="More than one HDU"): 42 | parse_input_data(hdulist) 43 | 44 | 45 | def test_parse_input_data_distortion_map(): 46 | # Verify that the file can be successfully loaded and parsed 47 | fname = get_pkg_data_filename("data/image_with_distortion_map.fits", package="reproject.tests") 48 | parse_input_data(fname, hdu_in=0) 49 | 50 | 51 | @pytest.mark.filterwarnings("ignore:unclosed file:ResourceWarning") 52 | def test_parse_input_shape(tmpdir, valid_celestial_input_shapes): 53 | """ 54 | This should support everything that parse_input_data does, *plus* an 55 | "array-like" argument that is just a shape rather than a populated array. 56 | """ 57 | 58 | array_ref, wcs_ref, input_value, kwargs = valid_celestial_input_shapes 59 | 60 | shape, wcs = parse_input_shape(input_value, **kwargs) 61 | assert shape == array_ref.shape 62 | assert_wcs_allclose(wcs, wcs_ref) 63 | 64 | 65 | def test_parse_input_shape_invalid(): 66 | data = np.ones((30, 40)) 67 | 68 | # Invalid 69 | with pytest.raises(TypeError) as exc: 70 | parse_input_shape(data) 71 | assert exc.value.args[0] == ( 72 | "input_shape should either be an HDU object or a tuple " 73 | "of (array-or-shape, WCS) or (array-or-shape, Header)" 74 | ) 75 | 76 | 77 | def test_parse_input_shape_missing_hdu_in(): 78 | hdulist = fits.HDUList( 79 | [fits.PrimaryHDU(data=np.ones((30, 40))), fits.ImageHDU(data=np.ones((20, 30)))] 80 | ) 81 | 82 | with pytest.raises(ValueError) as exc: 83 | shape, coordinate_system = parse_input_shape(hdulist) 84 | assert exc.value.args[0] == ( 85 | "More than one HDU is present, please specify HDU to use with ``hdu_in=`` option" 86 | ) 87 | 88 | 89 | def test_parse_output_projection(valid_celestial_output_projections): 90 | wcs_ref, shape_ref, output_value, kwargs = valid_celestial_output_projections 91 | 92 | wcs, shape = parse_output_projection(output_value, **kwargs) 93 | 94 | assert shape == shape_ref 95 | assert_wcs_allclose(wcs, wcs_ref) 96 | 97 | 98 | def test_parse_output_projection_invalid_header(simple_celestial_fits_wcs): 99 | with pytest.raises(ValueError, match="Need to specify shape"): 100 | parse_output_projection(simple_celestial_fits_wcs.to_header()) 101 | 102 | 103 | def test_parse_output_projection_invalid_wcs(simple_celestial_fits_wcs): 104 | with pytest.raises(ValueError, match="Need to specify shape"): 105 | parse_output_projection(simple_celestial_fits_wcs) 106 | 107 | 108 | def test_parse_output_projection_override_shape_out(simple_celestial_wcs): 109 | # Regression test for a bug that caused shape_out to be ignored if the 110 | # WCS object had array_shape set - but shape_out should override the WCS 111 | # shape. 112 | 113 | wcs_ref = simple_celestial_wcs 114 | 115 | set_wcs_array_shape(wcs_ref, (10, 20)) 116 | 117 | if hasattr(wcs_ref, "low_level_wcs"): 118 | assert wcs_ref.low_level_wcs.array_shape == (10, 20) 119 | else: 120 | assert wcs_ref.array_shape == (10, 20) 121 | 122 | wcs, shape = parse_output_projection(wcs_ref, shape_out=(30, 40)) 123 | 124 | assert shape == (30, 40) 125 | assert_wcs_allclose(wcs, wcs_ref) 126 | 127 | 128 | @pytest.mark.filterwarnings("ignore::astropy.utils.exceptions.AstropyUserWarning") 129 | @pytest.mark.filterwarnings("ignore::astropy.wcs.wcs.FITSFixedWarning") 130 | def test_has_celestial(): 131 | from .test_high_level import INPUT_HDR 132 | 133 | hdr = fits.Header.fromstring(INPUT_HDR) 134 | ww = WCS(hdr) 135 | assert ww.has_celestial 136 | assert has_celestial(ww) 137 | 138 | from astropy.wcs.wcsapi import HighLevelWCSWrapper, SlicedLowLevelWCS 139 | 140 | wwh = HighLevelWCSWrapper(SlicedLowLevelWCS(ww, Ellipsis)) 141 | assert has_celestial(wwh) 142 | 143 | wwh2 = HighLevelWCSWrapper(SlicedLowLevelWCS(ww, [slice(0, 1), slice(0, 1)])) 144 | assert has_celestial(wwh2) 145 | 146 | 147 | class TestHDUToMemmap: 148 | 149 | def test_compressed(self, tmp_path): 150 | 151 | hdu = fits.CompImageHDU(data=np.random.random((128, 128))) 152 | hdu.writeto(tmp_path / "test.fits") 153 | 154 | mmap = hdu_to_numpy_memmap(hdu) 155 | 156 | np.testing.assert_allclose(hdu.data, mmap) 157 | -------------------------------------------------------------------------------- /reproject/wcs_utils.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | 3 | """ 4 | WCS-related utilities 5 | """ 6 | 7 | import numpy as np 8 | from astropy.coordinates import SkyCoord 9 | from astropy.wcs import WCS 10 | from astropy.wcs.utils import pixel_to_pixel 11 | 12 | __all__ = ["has_celestial", "pixel_to_pixel_with_roundtrip"] 13 | 14 | 15 | def has_celestial(wcs): 16 | """ 17 | Returns `True` if there are celestial coordinates in the WCS. 18 | """ 19 | if isinstance(wcs, WCS): 20 | return wcs.has_celestial 21 | else: 22 | for world_axis_class in wcs.low_level_wcs.world_axis_object_classes.values(): 23 | if issubclass(world_axis_class[0], SkyCoord): 24 | return True 25 | return False 26 | 27 | 28 | def pixel_to_pixel_with_roundtrip(wcs1, wcs2, *inputs): 29 | outputs = pixel_to_pixel(wcs1, wcs2, *inputs) 30 | 31 | # Now convert back to check that coordinates round-trip, if not then set to NaN 32 | inputs_check = pixel_to_pixel(wcs2, wcs1, *outputs) 33 | reset = np.zeros(inputs_check[0].shape, dtype=bool) 34 | for ipix in range(len(inputs_check)): 35 | reset |= np.abs(inputs_check[ipix] - inputs[ipix]) > 1 36 | if np.any(reset): 37 | for ipix in range(len(inputs_check)): 38 | outputs[ipix] = outputs[ipix].copy() 39 | outputs[ipix][reset] = np.nan 40 | 41 | return outputs 42 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{311,312,313}-{test}{,-oldestdeps,-numpy121} 4 | build_docs 5 | codestyle 6 | isolated_build = True 7 | 8 | [testenv] 9 | whitelist_externals = 10 | geos-config 11 | passenv = 12 | SSL_CERT_FILE 13 | setenv = 14 | HOME = {envtmpdir} 15 | MPLBACKEND = Agg 16 | PYTEST_COMMAND = pytest --arraydiff --arraydiff-default-format=fits --pyargs reproject --cov reproject --cov-config={toxinidir}/pyproject.toml {toxinidir}/docs --remote-data 17 | devdeps: PIP_EXTRA_INDEX_URL = https://pypi.anaconda.org/liberfa/simple https://pypi.anaconda.org/astropy/simple https://pypi.anaconda.org/scientific-python-nightly-wheels/simple 18 | changedir = 19 | .tmp/{envname} 20 | deps = 21 | numpy121: numpy==1.21.* 22 | 23 | oldestdeps: numpy==1.23.* 24 | oldestdeps: astropy==5.0.* 25 | oldestdeps: astropy-healpix==1.0.* 26 | oldestdeps: scipy==1.9.* 27 | oldestdeps: dask==2021.8.* 28 | 29 | devdeps: numpy>=0.0.dev0 30 | devdeps: pyerfa>=0.0.dev0 31 | devdeps: scipy>=0.0.dev0 32 | devdeps: astropy>=0.0.dev0 33 | devdeps: astropy-healpix>=0.0.dev0 34 | # For now we don't test with asdf dev due to this issue: https://github.com/asdf-format/asdf/issues/1811 35 | #devdeps: asdf @ git+https://github.com/asdf-format/asdf.git 36 | #devdeps: asdf-astropy @ git+https://github.com/astropy/asdf-astropy.git 37 | devdeps: gwcs @ git+https://github.com/spacetelescope/gwcs.git 38 | devdeps: sunpy[map] @ git+https://github.com/sunpy/sunpy.git 39 | 40 | extras = 41 | test 42 | # Don't run the more complex tests on oldestdeps because it pulls in a nest 43 | # web of dependencies much newer than our mins 44 | !oldestdeps-!devdeps: testall 45 | install_command = 46 | !devdeps: python -I -m pip install {opts} {packages} 47 | devdeps: python -I -m pip install {opts} {packages} --pre 48 | commands = 49 | pip freeze 50 | !oldestdeps: {env:PYTEST_COMMAND} {posargs} 51 | oldestdeps: {env:PYTEST_COMMAND} -W ignore::RuntimeWarning {posargs} 52 | # Clear the download cache from the .tox directory - this is done to 53 | # avoid issues in the continuous integration when uploading results 54 | python -c 'from astropy.utils.data import clear_download_cache; clear_download_cache()' 55 | 56 | [testenv:build_docs] 57 | changedir = docs 58 | description = invoke sphinx-build to build the HTML docs 59 | extras = docs 60 | commands = 61 | pip freeze 62 | sphinx-build -W -b html . _build/html 63 | 64 | [testenv:linkcheck] 65 | changedir = docs 66 | description = check the links in the HTML docs 67 | extras = docs 68 | commands = 69 | pip freeze 70 | sphinx-build -W -b linkcheck . _build/html 71 | 72 | [testenv:codestyle] 73 | skip_install = true 74 | description = Run all style and file checks with pre-commit 75 | deps = 76 | pre-commit 77 | commands = 78 | pre-commit install-hooks 79 | pre-commit run --color always --all-files --show-diff-on-failure 80 | --------------------------------------------------------------------------------