├── statmorph ├── __init__.py ├── tests │ ├── __init__.py │ ├── data │ │ └── slice.fits │ └── test_statmorph.py └── utils │ ├── __init__.py │ ├── tests │ └── test_image_diagnostics.py │ └── image_diagnostics.py ├── docs ├── requirements.txt ├── _static │ ├── logo.png │ ├── favicon.ico │ └── banner.svg ├── examples.rst ├── api.rst ├── Makefile ├── installation.rst ├── index.rst ├── overview.rst ├── conf.py ├── description.rst └── notebooks │ ├── tutorial.ipynb │ └── doublesersic.ipynb ├── requirements.txt ├── MANIFEST.in ├── .readthedocs.yaml ├── .gitignore ├── .github └── workflows │ └── ci.yml ├── setup.py ├── LICENSE └── README.rst /statmorph/__init__.py: -------------------------------------------------------------------------------- 1 | from .statmorph import * 2 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | ipykernel 2 | nbsphinx 3 | matplotlib 4 | -------------------------------------------------------------------------------- /statmorph/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .test_statmorph import * 2 | -------------------------------------------------------------------------------- /statmorph/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .image_diagnostics import * 2 | -------------------------------------------------------------------------------- /docs/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrodgom/statmorph/HEAD/docs/_static/logo.png -------------------------------------------------------------------------------- /docs/_static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrodgom/statmorph/HEAD/docs/_static/favicon.ico -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy>=1.14.0 2 | scipy>=0.19 3 | scikit-image>=0.25.2 4 | astropy>=2.0 5 | photutils>=2.0 6 | -------------------------------------------------------------------------------- /statmorph/tests/data/slice.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vrodgom/statmorph/HEAD/statmorph/tests/data/slice.fits -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | 2 | Other examples 3 | ============== 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | notebooks/doublesersic 9 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | 2 | API Reference 3 | ============= 4 | 5 | .. autoclass:: statmorph.SourceMorphology 6 | :members: 7 | 8 | .. autofunction:: statmorph.source_morphology 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include *.rst *.txt 3 | include docs/*.rst docs/*.txt docs/*.py docs/Makefile 4 | include docs/notebooks/*.ipynb docs/_static/* 5 | recursive-include statmorph *.py *.fits 6 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-22.04" 5 | tools: 6 | python: "3.11" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - requirements: docs/requirements.txt 14 | - requirements: requirements.txt 15 | - method: pip 16 | path: . 17 | extra_requirements: 18 | - docs 19 | 20 | formats: [] 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python stuff 2 | *.pyc 3 | __pycache__/* 4 | 5 | # Images 6 | *.png 7 | plots/* 8 | 9 | # Profiling 10 | *.prof 11 | 12 | # Documentation 13 | docs/_build/* 14 | docs/_templates/* 15 | docs/notebooks/.ipynb_checkpoints/* 16 | 17 | # Packaging 18 | dist/* 19 | statmorph.egg-info/* 20 | 21 | # PyCharm stuff 22 | .idea 23 | 24 | # Other, custom 25 | *.sh 26 | notebooks/.ipynb_checkpoints/* 27 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = statmorph 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | 2 | Installation 3 | ============ 4 | 5 | The easiest way to install this package is within the Anaconda environment: 6 | 7 | .. code:: bash 8 | 9 | conda install -c conda-forge statmorph 10 | 11 | Alternatively, assuming that you already have recent versions of scipy, 12 | scikit-image, astropy and photutils installed, statmorph can also be 13 | installed via PyPI: 14 | 15 | .. code:: bash 16 | 17 | pip install statmorph 18 | 19 | Finally, if you prefer a manual installation, download the latest release 20 | from the `GitHub repository `_, 21 | extract the contents of the zipfile, and run: 22 | 23 | .. code:: bash 24 | 25 | python setup.py install 26 | 27 | **Running the built-in tests** 28 | 29 | To test that the installation was successful, run: 30 | 31 | .. code:: bash 32 | 33 | python -c "import statmorph.tests; statmorph.tests.runall()" 34 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | # run every Sunday (0th day) at 12:00 UTC (syntax: m h * * day) 8 | - cron: '0 12 * * 0' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ['3.10', '3.11', '3.12'] 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install -r requirements.txt 26 | pip install pytest-cov codecov 27 | pip install . 28 | - name: Test with pytest 29 | run: | 30 | pytest --cov=statmorph 31 | - name: Upload coverage to Codecov 32 | uses: codecov/codecov-action@v3 33 | -------------------------------------------------------------------------------- /statmorph/utils/tests/test_image_diagnostics.py: -------------------------------------------------------------------------------- 1 | """ 2 | Some trivial tests for the ``image_diagnostics`` plotting utility. 3 | Usually these are skipped, since Matplotlib is not listed in the 4 | statmorph requirements. 5 | """ 6 | # Author: Vicente Rodriguez-Gomez 7 | # Licensed under a 3-Clause BSD License. 8 | import numpy as np 9 | import os 10 | import pytest 11 | import statmorph 12 | from astropy.io import fits 13 | try: 14 | import matplotlib 15 | from statmorph.utils import make_figure 16 | HAS_MATPLOTLIB = True 17 | except ImportError: 18 | HAS_MATPLOTLIB = False 19 | 20 | @pytest.mark.skipif('not HAS_MATPLOTLIB', reason='Requires Matplotlib.') 21 | def test_invalid_input(): 22 | with pytest.raises(TypeError): 23 | make_figure('foo') 24 | 25 | @pytest.mark.skipif('not HAS_MATPLOTLIB', reason='Requires Matplotlib.') 26 | def test_make_figure(): 27 | curdir = os.path.dirname(__file__) 28 | hdulist = fits.open('%s/../../tests/data/slice.fits' % (curdir,)) 29 | image = hdulist[0].data 30 | segmap = hdulist[1].data 31 | mask = np.bool_(hdulist[2].data) 32 | gain = 1.0 33 | source_morphs = statmorph.source_morphology(image, segmap, mask=mask, gain=gain) 34 | morph = source_morphs[0] 35 | fig = make_figure(morph) 36 | assert isinstance(fig, matplotlib.figure.Figure) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | # To use a consistent encoding 3 | from codecs import open 4 | from os import path 5 | 6 | here = path.abspath(path.dirname(__file__)) 7 | 8 | # Get the long description from the README file 9 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 10 | long_description = f.read() 11 | 12 | setup( 13 | name='statmorph', 14 | version='0.7.1', 15 | description='Non-parametric morphological diagnostics of galaxy images', 16 | long_description=long_description, 17 | url='https://github.com/vrodgom/statmorph', 18 | author='Vicente Rodriguez-Gomez', 19 | author_email='vrodgom.astro@gmail.com', 20 | license='BSD', 21 | classifiers=[ 22 | 'Development Status :: 4 - Beta', 23 | 'Intended Audience :: Science/Research', 24 | 'License :: OSI Approved :: BSD License', 25 | 'Programming Language :: Python', 26 | 'Programming Language :: Python :: 3', 27 | 'Topic :: Scientific/Engineering :: Astronomy', 28 | ], 29 | keywords='astronomy galaxies galaxy-morphology non-parametric', 30 | packages=['statmorph', 'statmorph.tests'], 31 | include_package_data=True, 32 | install_requires=['numpy>=1.14.0', 33 | 'scipy>=0.19', 34 | 'scikit-image>=0.25.2', 35 | 'astropy>=2.0', 36 | 'photutils>=2.0'], 37 | python_requires='>=3.9', 38 | extras_require={ 39 | 'matplotlib': ['matplotlib>=3.0']}, 40 | ) 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017-2025, Vicente Rodriguez-Gomez 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. statmorph documentation master file, created by 2 | sphinx-quickstart on Tue Oct 17 15:22:32 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | statmorph 7 | ========= 8 | 9 | .. ~ **statmorph** is a Python package for calculating non-parametric morphological 10 | .. ~ diagnostics of galaxy images, including the Gini-:math:`M_{20}` statistics 11 | .. ~ (Lotz et al. 2004), the CAS statistics (Conselice 2003), the MID statistics 12 | .. ~ (Freeman et al. 2013), and the shape asymmetry index (Pawlik et al. 2016). 13 | .. ~ The code also performs single-component Sérsic fits. 14 | 15 | .. ~ .. raw:: html 16 | 17 | .. ~ 18 | 19 | **statmorph** is an 20 | `affiliated package of Astropy `_ 21 | for calculating non-parametric morphological diagnostics of galaxy images 22 | (e.g., Gini-:math:`M_{20}` and CAS statistics), as well as fitting 2D Sérsic profiles. 23 | 24 | .. toctree:: 25 | :maxdepth: 2 26 | :caption: Contents: 27 | 28 | overview 29 | installation 30 | description 31 | notebooks/tutorial 32 | examples 33 | api 34 | 35 | Citing 36 | ------ 37 | 38 | If you use this code for a scientific publication, please cite the following 39 | article: 40 | 41 | - `Rodriguez-Gomez et al. (2019) `_ 42 | 43 | 44 | Indices and tables 45 | ------------------ 46 | 47 | * :ref:`genindex` 48 | * :ref:`modindex` 49 | * :ref:`search` 50 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | statmorph 2 | ========= 3 | 4 | .. image:: https://img.shields.io/pypi/v/statmorph.svg 5 | :target: https://pypi.org/project/statmorph 6 | :alt: PyPI Version 7 | .. image:: https://anaconda.org/conda-forge/statmorph/badges/version.svg 8 | :target: https://anaconda.org/conda-forge/statmorph 9 | :alt: Conda Version 10 | .. image:: https://anaconda.org/conda-forge/statmorph/badges/downloads.svg 11 | :target: https://anaconda.org/conda-forge/statmorph 12 | :alt: Conda Downloads 13 | .. image:: https://github.com/vrodgom/statmorph/workflows/CI/badge.svg 14 | :target: https://github.com/vrodgom/statmorph/actions 15 | :alt: Build Status 16 | .. image:: https://img.shields.io/codecov/c/github/vrodgom/statmorph 17 | :target: https://codecov.io/gh/vrodgom/statmorph 18 | :alt: Coverage Status 19 | .. image:: https://readthedocs.org/projects/statmorph/badge/?version=latest 20 | :target: https://statmorph.readthedocs.io/en/latest/?badge=latest 21 | :alt: Documentation Status 22 | 23 | Python code for calculating non-parametric morphological diagnostics of 24 | galaxy images. 25 | 26 | Documentation 27 | ------------- 28 | 29 | The documentation and installation instructions can be found on 30 | `ReadTheDocs `_. 31 | 32 | Tutorial / How to use 33 | --------------------- 34 | 35 | Please see the 36 | `statmorph tutorial `_. 37 | 38 | Citing 39 | ------ 40 | 41 | If you use this code for a scientific publication, please cite the following 42 | article: 43 | 44 | - `Rodriguez-Gomez et al. (2019) `_ 45 | 46 | Licensing 47 | --------- 48 | 49 | Licensed under a 3-Clause BSD License. 50 | -------------------------------------------------------------------------------- /docs/overview.rst: -------------------------------------------------------------------------------- 1 | 2 | Overview 3 | ============ 4 | 5 | For a given (background-subtracted) image and a corresponding segmentation map 6 | indicating the source(s) of interest, statmorph calculates the following 7 | morphological statistics for each source: 8 | 9 | - Gini-M20 statistics (Lotz et al. 2004; Snyder et al. 2015a,b) 10 | - Concentration, Asymmetry and Smoothness (CAS) statistics 11 | (Bershady et al. 2000; Conselice 2003; Lotz et al. 2004) 12 | - Multimode, Intensity and Deviation (MID) statistics (Freeman et al. 2013; 13 | Peth et al. 2016) 14 | - RMS asymmetry (Sazonova et al. 2024), outer asymmetry (Wen et al. 2014) and shape asymmetry (Pawlik et al. 2016) 15 | - Single and double Sérsic indices (Sérsic 1968) 16 | - Several shape and size measurements associated to the above statistics 17 | (ellipticity, Petrosian radius, half-light radius, etc.) 18 | 19 | .. ~ For more information, please see: 20 | 21 | .. ~ - `Rodriguez-Gomez et al. (2019) `_ 22 | 23 | The current Python implementation is largely based on IDL and Python code 24 | originally written by Jennifer Lotz and Greg Snyder. 25 | 26 | **Authors** 27 | 28 | - Vicente Rodriguez-Gomez (vrodgom.astro@gmail.com) 29 | - Jennifer Lotz 30 | - Greg Snyder 31 | 32 | **Acknowledgments** 33 | 34 | - We thank Peter Freeman and Mike Peth for sharing their IDL 35 | implementation of the MID statistics. 36 | 37 | **Citing** 38 | 39 | If you use this code for a scientific publication, please cite the following 40 | article: 41 | 42 | - `Rodriguez-Gomez et al. (2019) `_ 43 | 44 | .. ~ Optionally, the Python package can also be cited using its Zenodo record: 45 | 46 | .. ~ .. image:: https://zenodo.org/badge/95412529.svg 47 | .. ~ :target: https://zenodo.org/badge/latestdoi/95412529 48 | 49 | **Disclaimer** 50 | 51 | This package is not meant to be the "official" implementation of any 52 | of the morphological statistics listed above. Please contact the 53 | authors of the original publications for a "reference" implementation. 54 | 55 | **Licensing** 56 | 57 | Licensed under a 3-Clause BSD License. 58 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'statmorph' 21 | copyright = '2017-2025, Vicente Rodriguez-Gomez' 22 | author = 'Vicente Rodriguez-Gomez' 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = '0.7.1' 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | 'sphinx.ext.autodoc', 35 | 'sphinx.ext.imgmath', 36 | 'sphinx.ext.viewcode', 37 | 'sphinx.ext.napoleon', 38 | 'sphinx.ext.mathjax', 39 | 'nbsphinx', 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # The suffix(es) of source filenames. 46 | # You can specify multiple suffix as a list of string: 47 | # source_suffix = ['.rst', '.md'] 48 | source_suffix = '.rst' 49 | 50 | # The master toctree document. 51 | master_doc = 'index' 52 | 53 | # List of patterns, relative to source directory, that match files and 54 | # directories to ignore when looking for source files. 55 | # This pattern also affects html_static_path and html_extra_path. 56 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', '.ipynb_checkpoints'] 57 | 58 | # The name of the Pygments (syntax highlighting) style to use. 59 | pygments_style = 'sphinx' 60 | 61 | ## Uncomment to allow errors in the tutorial notebook: 62 | #nbsphinx_allow_errors = True 63 | 64 | # -- Options for HTML output ------------------------------------------------- 65 | 66 | # The theme to use for HTML and HTML Help pages. See the documentation for 67 | # a list of builtin themes. 68 | # 69 | html_theme = 'alabaster' 70 | html_favicon = '_static/favicon.ico' 71 | html_theme_options = { 72 | 'logo': 'logo.png', 73 | 'logo_name': False, 74 | } 75 | 76 | # Add any paths that contain custom static files (such as style sheets) here, 77 | # relative to this directory. They are copied after the builtin static files, 78 | # so a file named "default.css" will overwrite the builtin "default.css". 79 | html_static_path = ['_static'] 80 | -------------------------------------------------------------------------------- /docs/description.rst: -------------------------------------------------------------------------------- 1 | 2 | Brief description 3 | ================= 4 | 5 | The main interface between the user and the code is the `source_morphology` 6 | function, which calculates the morphological parameters of a set of sources. 7 | Below we briefly describe the input and output of this function. 8 | 9 | A more detailed description of the input parameters and the measurements 10 | performed by statmorph can be found in the API reference, as well as in 11 | `Rodriguez-Gomez et al. (2019) `_. 12 | We also refer the user to the 13 | `tutorial `_, 14 | which contains a more concrete (albeit simplified) usage example. 15 | 16 | Input 17 | ----- 18 | 19 | The main two *required* input parameters are the following: 20 | 21 | - ``image`` : A *background-subtracted* image (2D array) containing the 22 | source(s) of interest. 23 | - ``segmap`` : A segmentation map (2D array) of the same size as the image with 24 | different sources labeled by different positive integer numbers. A value of 25 | zero is reserved for the background. 26 | 27 | In addition, *one* of the following two parameters is also required: 28 | 29 | - ``weightmap`` : A 2D array (of the same size as the image) representing one 30 | standard deviation of each pixel value. This is also known as the "sigma" 31 | image and is related to the Poisson noise. If the weight map is not 32 | provided by the user, then it is computed internally using the ``gain`` 33 | keyword argument. 34 | - ``gain`` : A scalar that, when multiplied by the image, converts the image 35 | units into electrons/pixel. This parameter is required when ``weightmap`` 36 | is not provided by the user. 37 | 38 | Optionally, the function can also accept: 39 | 40 | - ``mask`` : A 2D array (of the same size as the image) indicating the pixels 41 | that should be masked (e.g., to remove contamination from foreground stars). 42 | - ``psf`` : A 2D array (usually smaller than the image) representing the point 43 | spread function (PSF). This is used when fitting Sersic profiles. 44 | 45 | In addition, almost all of the parameters used in the calculation of the 46 | morphological diagnostics can be specified by the user as keyword 47 | arguments, although it is recommended to leave the default values alone. 48 | An exception might be the ``cutout_extent`` parameter, which specifies the 49 | extent of the region (as a multiple of the size of the appropriate segment 50 | in the segmentation map) used by statmorph to perform the morphological 51 | measurements. Since the segmentation map is user-defined, in some cases it 52 | makes sense to increase the value of ``cutout_extent`` from 2.5 (the default) 53 | to 5 or 10, depending on the sensitivity of the original segmentation map. 54 | 55 | For a complete list of keyword arguments, please see the 56 | `API Reference `_. 57 | 58 | Output 59 | ------ 60 | 61 | The output of the `source_morphology` function is a list of 62 | `SourceMorphology` objects, one for each labeled source, in which the 63 | different morphological measurements can be accessed as keys or attributes. 64 | 65 | Apart from the morphological parameters, statmorph also returns three 66 | quality flags: 67 | 68 | - ``flag`` : indicates the quality of the basic morphological measurements. 69 | It can take one of the following values: 70 | 71 | - 0 (good): there were no problems with the measurements. 72 | - 1 (suspect): the Gini segmap is discontinuous (e.g., due to a secondary 73 | source that was not properly labeled/masked) or the Gini and MID segmaps 74 | are very different from each other (as determined by the 75 | ``segmap_overlap_ratio`` keyword argument). 76 | - 2 (bad): there were problems with the measurements (e.g., the asymmetry 77 | minimizer tried to exit the image boundaries). However, most measurements 78 | are attempted anyway and a non-null value (i.e., not -99) might be 79 | returned for most measurements. 80 | - 3 (n/a): not currently used. 81 | - 4 (catastrophic): this value is returned when even the most basic 82 | measurements would be futile (e.g., a source with a negative total flux). 83 | This replaces the ``flag_catastrophic`` from earlier versions of statmorph. 84 | 85 | - ``flag_sersic`` : indicates the quality of the Sersic fit. Just like 86 | ``flag``, it can take the following values: 0 (good), 1 (suspect), 2 (bad), 87 | and 4 (catastrophic). 88 | 89 | - ``flag_doublesersic`` : indicates the quality of the double Sersic fit. 90 | It can take values of 0 (good), 1 (suspect), 2 (bad), and 4 (catastrophic). 91 | This flag is only generated when statmorph is called with the option 92 | ``include_doublesersic = True``. 93 | 94 | 95 | In general, users should enforce ``flag <= 1``, while ``flag_sersic <= 1`` 96 | and ``flag_doublesersic <= 1`` should only be imposed when the user is 97 | actually interested in the corresponding model fits (which, naturally, can 98 | fail when the model is not a good description of the data). 99 | 100 | In addition to the flags described above, the output should usually 101 | not be trusted when the smallest of the measured distance scales (``r20``) 102 | is smaller than the radius at half-maximum of the PSF, 103 | or when the signal-to-noise per pixel (``sn_per_pixel``) is lower than 2.5 104 | (`Lotz et al. 2006 `_). 105 | -------------------------------------------------------------------------------- /docs/notebooks/tutorial.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "## Tutorial / How to use\n", 8 | "\n", 9 | "In this tutorial we create a (simplified) synthetic galaxy image from scratch, along with its associated segmentation map, and then run the statmorph code on it.\n", 10 | "\n", 11 | "If you already have a real astronomical image and segmentation map to work with, jump to [Running statmorph](#Running-statmorph).\n", 12 | "\n", 13 | "\n", 14 | "### Setting up\n", 15 | "\n", 16 | "We import some Python packages first. If you are missing any of these, please see the the installation instructions." 17 | ] 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": null, 22 | "metadata": {}, 23 | "outputs": [], 24 | "source": [ 25 | "import numpy as np\n", 26 | "import matplotlib.pyplot as plt\n", 27 | "from astropy.visualization import simple_norm\n", 28 | "from astropy.modeling.models import Sersic2D\n", 29 | "from astropy.convolution import convolve, Gaussian2DKernel\n", 30 | "from photutils.segmentation import detect_threshold, detect_sources\n", 31 | "import time\n", 32 | "import statmorph\n", 33 | "%matplotlib inline" 34 | ] 35 | }, 36 | { 37 | "cell_type": "markdown", 38 | "metadata": {}, 39 | "source": [ 40 | "**Creating a model galaxy image**\n", 41 | "\n", 42 | "We assume that the image size is 240x240 pixels and that the \"true\" light distribution is described by a 2D Sersic model with the following parameters:" 43 | ] 44 | }, 45 | { 46 | "cell_type": "code", 47 | "execution_count": null, 48 | "metadata": {}, 49 | "outputs": [], 50 | "source": [ 51 | "ny, nx = 240, 240\n", 52 | "y, x = np.mgrid[0:ny, 0:nx]\n", 53 | "sersic_model = Sersic2D(\n", 54 | " amplitude=1, r_eff=20, n=2.5, x_0=120.5, y_0=96.5,\n", 55 | " ellip=0.5, theta=0.5)\n", 56 | "image = sersic_model(x, y)\n", 57 | "plt.imshow(image, cmap='gray', origin='lower',\n", 58 | " norm=simple_norm(image, stretch='log', log_a=10000))" 59 | ] 60 | }, 61 | { 62 | "cell_type": "markdown", 63 | "metadata": {}, 64 | "source": [ 65 | "**Convolving with a PSF**\n", 66 | "\n", 67 | "In practice, every astronomical image is the convolution of a \"true\" image with a point spread function (PSF), which depends on the optics of the telescope, atmospheric conditions, etc. Here we assume that the PSF is a simple 2D Gaussian kernel with a standard deviation of 2 pixels:" 68 | ] 69 | }, 70 | { 71 | "cell_type": "code", 72 | "execution_count": null, 73 | "metadata": {}, 74 | "outputs": [], 75 | "source": [ 76 | "kernel = Gaussian2DKernel(2)\n", 77 | "kernel.normalize() # make sure kernel adds up to 1\n", 78 | "psf = kernel.array # we only need the numpy array\n", 79 | "plt.imshow(psf, origin='lower', cmap='gray')" 80 | ] 81 | }, 82 | { 83 | "cell_type": "markdown", 84 | "metadata": {}, 85 | "source": [ 86 | "Now we convolve the image with the PSF." 87 | ] 88 | }, 89 | { 90 | "cell_type": "code", 91 | "execution_count": null, 92 | "metadata": {}, 93 | "outputs": [], 94 | "source": [ 95 | "image = convolve(image, psf)\n", 96 | "plt.imshow(image, cmap='gray', origin='lower',\n", 97 | " norm=simple_norm(image, stretch='log', log_a=10000))" 98 | ] 99 | }, 100 | { 101 | "cell_type": "markdown", 102 | "metadata": {}, 103 | "source": [ 104 | "**Applying shot noise**\n", 105 | "\n", 106 | "One source of noise in astronomical images originates from the Poisson statistics of the number of electrons recorded by each pixel. We can model this effect by introducing a *gain* parameter, a scalar that can be multiplied by the science image to obtain the number of electrons per pixel.\n", 107 | "\n", 108 | "For the sake of this example, we choose a very large gain value, so that shot noise becomes almost negligible (10^5 electrons/pixel at the effective radius, where we had defined an amplitude of 1.0 in arbitrary units). The resulting image after applying shot noise looks very similar to the one from the previous step and is not shown." 109 | ] 110 | }, 111 | { 112 | "cell_type": "code", 113 | "execution_count": null, 114 | "metadata": {}, 115 | "outputs": [], 116 | "source": [ 117 | "np.random.seed(3)\n", 118 | "gain = 1e5\n", 119 | "image = np.random.poisson(image * gain) / gain" 120 | ] 121 | }, 122 | { 123 | "cell_type": "markdown", 124 | "metadata": {}, 125 | "source": [ 126 | "**Applying background noise**\n", 127 | "\n", 128 | "Apart from shot noise, astronomical images have a sky background noise component, which we here model with a uniform Gaussian distribution centered at zero (since the image is background-subtracted).\n", 129 | "\n", 130 | "We assume, somewhat optimistically, that the signal-to-noise ratio (S/N) per pixel is 100 at the effective radius (where we had defined the Sersic model amplitude as 1.0)." 131 | ] 132 | }, 133 | { 134 | "cell_type": "code", 135 | "execution_count": null, 136 | "metadata": {}, 137 | "outputs": [], 138 | "source": [ 139 | "snp = 100.0\n", 140 | "sky_sigma = 1.0 / snp\n", 141 | "image += sky_sigma * np.random.standard_normal(size=(ny, nx))\n", 142 | "plt.imshow(image, cmap='gray', origin='lower',\n", 143 | " norm=simple_norm(image, stretch='log', log_a=10000))" 144 | ] 145 | }, 146 | { 147 | "cell_type": "markdown", 148 | "metadata": {}, 149 | "source": [ 150 | "**Gain and weight maps**\n", 151 | "\n", 152 | "Note that statmorph will ask for one of two input arguments: (1) a weight map, which is a 2D array (of the same size as the input image) representing one standard deviation at each pixel value, or (2) the gain, which was described above. In the latter case, the gain parameter is used internally by statmorph (along with an automatic estimation of the sky background noise) to calculate the weight map." 153 | ] 154 | }, 155 | { 156 | "cell_type": "markdown", 157 | "metadata": {}, 158 | "source": [ 159 | "**Creating a segmentation map**\n", 160 | "\n", 161 | "Besides the image itself and the weight map/gain, the only other *required* argument is the segmentation map, which labels the pixels belonging to different sources. It is usually generated with specialized tools such as SExtractor or photutils. Here we use the latter to create a simplified segmentation map, where we detect sources that lie above a 1.5-sigma detection threshold.\n", 162 | "\n", 163 | "Note that the detection stage (but, importantly, not the threshold calculation) is carried out on a \"convolved\" version of the image. This is done in order to smooth out small-scale noise and thus ensure that the shapes of the detected segments are reasonably smooth." 164 | ] 165 | }, 166 | { 167 | "cell_type": "code", 168 | "execution_count": null, 169 | "metadata": {}, 170 | "outputs": [], 171 | "source": [ 172 | "threshold = detect_threshold(image, 1.5)\n", 173 | "npixels = 5 # minimum number of connected pixels\n", 174 | "convolved_image = convolve(image, psf)\n", 175 | "segmap = detect_sources(convolved_image, threshold, npixels)\n", 176 | "plt.imshow(segmap, origin='lower', cmap='gray')" 177 | ] 178 | }, 179 | { 180 | "cell_type": "markdown", 181 | "metadata": {}, 182 | "source": [ 183 | "In this particular case, the obtained segmap has only two values: 0 for the background (as should always be the case) and 1 for the only labeled source. However, statmorph is designed to process all the sources labeled by a segmentation map, which makes it applicable to large mosaic images." 184 | ] 185 | }, 186 | { 187 | "cell_type": "markdown", 188 | "metadata": {}, 189 | "source": [ 190 | "### Running statmorph\n", 191 | "\n", 192 | "Now that we have all the required data, we are ready to measure the morphology of the source just created. Note that we include the PSF as a keyword argument, which results in more correct Sersic profile fits.\n", 193 | "\n", 194 | "Also note that we do not attempt to fit a *double* Sersic model, which would be degenerate in this particular case (the two components would be identical and their relative amplitudes would be unconstrained). For a demonstration of statmorph's double Sersic fitting functionality, see the [Double 2D Sersic example](./doublesersic.html)." 195 | ] 196 | }, 197 | { 198 | "cell_type": "code", 199 | "execution_count": null, 200 | "metadata": {}, 201 | "outputs": [], 202 | "source": [ 203 | "start = time.time()\n", 204 | "source_morphs = statmorph.source_morphology(\n", 205 | " image, segmap, gain=gain, psf=psf)\n", 206 | "print('Time: %g s.' % (time.time() - start))" 207 | ] 208 | }, 209 | { 210 | "cell_type": "markdown", 211 | "metadata": {}, 212 | "source": [ 213 | "### Examining the output\n", 214 | "\n", 215 | "In general, `source_morphs` is a list of objects corresponding to each labeled source in the image. Here we focus on the first (and only) labeled source:" 216 | ] 217 | }, 218 | { 219 | "cell_type": "code", 220 | "execution_count": null, 221 | "metadata": {}, 222 | "outputs": [], 223 | "source": [ 224 | "morph = source_morphs[0]" 225 | ] 226 | }, 227 | { 228 | "cell_type": "markdown", 229 | "metadata": {}, 230 | "source": [ 231 | "Now we print some of the morphological properties just calculated:" 232 | ] 233 | }, 234 | { 235 | "cell_type": "code", 236 | "execution_count": null, 237 | "metadata": {}, 238 | "outputs": [], 239 | "source": [ 240 | "print('BASIC MEASUREMENTS (NON-PARAMETRIC)')\n", 241 | "print('xc_centroid =', morph.xc_centroid)\n", 242 | "print('yc_centroid =', morph.yc_centroid)\n", 243 | "print('ellipticity_centroid =', morph.ellipticity_centroid)\n", 244 | "print('elongation_centroid =', morph.elongation_centroid)\n", 245 | "print('orientation_centroid =', morph.orientation_centroid)\n", 246 | "print('xc_asymmetry =', morph.xc_asymmetry)\n", 247 | "print('yc_asymmetry =', morph.yc_asymmetry)\n", 248 | "print('ellipticity_asymmetry =', morph.ellipticity_asymmetry)\n", 249 | "print('elongation_asymmetry =', morph.elongation_asymmetry)\n", 250 | "print('orientation_asymmetry =', morph.orientation_asymmetry)\n", 251 | "print('rpetro_circ =', morph.rpetro_circ)\n", 252 | "print('rpetro_ellip =', morph.rpetro_ellip)\n", 253 | "print('rhalf_circ =', morph.rhalf_circ)\n", 254 | "print('rhalf_ellip =', morph.rhalf_ellip)\n", 255 | "print('r20 =', morph.r20)\n", 256 | "print('r80 =', morph.r80)\n", 257 | "print('Gini =', morph.gini)\n", 258 | "print('M20 =', morph.m20)\n", 259 | "print('F(G, M20) =', morph.gini_m20_bulge)\n", 260 | "print('S(G, M20) =', morph.gini_m20_merger)\n", 261 | "print('sn_per_pixel =', morph.sn_per_pixel)\n", 262 | "print('C =', morph.concentration)\n", 263 | "print('A =', morph.asymmetry)\n", 264 | "print('S =', morph.smoothness)\n", 265 | "print()\n", 266 | "print('SERSIC MODEL')\n", 267 | "print('sersic_amplitude =', morph.sersic_amplitude)\n", 268 | "print('sersic_rhalf =', morph.sersic_rhalf)\n", 269 | "print('sersic_n =', morph.sersic_n)\n", 270 | "print('sersic_xc =', morph.sersic_xc)\n", 271 | "print('sersic_yc =', morph.sersic_yc)\n", 272 | "print('sersic_ellip =', morph.sersic_ellip)\n", 273 | "print('sersic_theta =', morph.sersic_theta)\n", 274 | "print('sersic_chi2_dof =', morph.sersic_chi2_dof)\n", 275 | "print()\n", 276 | "print('OTHER')\n", 277 | "print('sky_mean =', morph.sky_mean)\n", 278 | "print('sky_median =', morph.sky_median)\n", 279 | "print('sky_sigma =', morph.sky_sigma)\n", 280 | "print('flag =', morph.flag)\n", 281 | "print('flag_sersic =', morph.flag_sersic)" 282 | ] 283 | }, 284 | { 285 | "cell_type": "markdown", 286 | "metadata": {}, 287 | "source": [ 288 | "Note that the fitted Sersic model is in very good agreement with the \"true\" Sersic model that we originally defined (n = 2.5, rhalf = 20, etc.) and that the reduced chi-squared statistic (sersic_chi2_dof) is close to 1, indicating a good fit without overfitting. However, such good agreement tends to deteriorate somewhat at higher noise levels, and one has to keep in mind that not all galaxies are well described by Sersic profiles.\n", 289 | "\n", 290 | "Other morphological measurements that are more general and robust to noise, which are also calculated by statmorph, include the Gini-M20 (Lotz et al. 2004), CAS (Conselice 2003) and MID (Freeman et al. 2013) statistics, as well as the RMS asymmetry (Sazonova et al. 2024), outer asymmetry (Wen et al. 2014), and shape asymmetry (Pawlik et al. 2016).\n", 291 | "\n", 292 | "Also note that statmorph returns two quality flags:\n", 293 | "\n", 294 | "1. ``flag`` : indicates the quality of the basic morphological measurements, taking one of the following values: 0 (good), 1 (suspect), 2 (bad), or 4 (catastrophic). More details can be found [here](../description.html#output).\n", 295 | "\n", 296 | "2. ``flag_sersic`` : indicates the quality of the Sersic fit, also taking values of 0 (good), 1 (suspect), 2 (bad), or 4 (catastrophic).\n", 297 | "\n", 298 | "In general, ``flag <= 1`` should always be enforced, while ``flag_sersic <= 1`` should only be used when one is interested in the Sersic fits (which might fail for merging galaxies and other \"irregular\" objects)." 299 | ] 300 | }, 301 | { 302 | "cell_type": "markdown", 303 | "metadata": {}, 304 | "source": [ 305 | "**Visualizing the morphological measurements**\n", 306 | "\n", 307 | "For convenience, statmorph includes a ``make_figure`` function that can be used to visualize some of the morphological measurements. This creates a multi-panel figure analogous to Fig. 4 from Rodriguez-Gomez et al. (2019)." 308 | ] 309 | }, 310 | { 311 | "cell_type": "code", 312 | "execution_count": null, 313 | "metadata": {}, 314 | "outputs": [], 315 | "source": [ 316 | "from statmorph.utils.image_diagnostics import make_figure\n", 317 | "fig = make_figure(morph)" 318 | ] 319 | }, 320 | { 321 | "cell_type": "code", 322 | "execution_count": null, 323 | "metadata": {}, 324 | "outputs": [], 325 | "source": [ 326 | "fig.savefig('tutorial.png', dpi=150)\n", 327 | "plt.close(fig)" 328 | ] 329 | } 330 | ], 331 | "metadata": { 332 | "kernelspec": { 333 | "display_name": "Python 3 (ipykernel)", 334 | "language": "python", 335 | "name": "python3" 336 | }, 337 | "language_info": { 338 | "codemirror_mode": { 339 | "name": "ipython", 340 | "version": 3 341 | }, 342 | "file_extension": ".py", 343 | "mimetype": "text/x-python", 344 | "name": "python", 345 | "nbconvert_exporter": "python", 346 | "pygments_lexer": "ipython3", 347 | "version": "3.11.6" 348 | } 349 | }, 350 | "nbformat": 4, 351 | "nbformat_minor": 4 352 | } 353 | -------------------------------------------------------------------------------- /docs/_static/banner.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 27 | 28 | 36 | 40 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 79 | 93 | 100 | 101 | 102 | 103 | 104 | statAn Astropy Package for Morphology 133 | -------------------------------------------------------------------------------- /statmorph/utils/image_diagnostics.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file defines the `make_figure` function, which can be useful for 3 | debugging and/or examining the morphology of a source in detail. 4 | """ 5 | # Author: Vicente Rodriguez-Gomez 6 | # Licensed under a 3-Clause BSD License. 7 | 8 | import numpy as np 9 | import skimage.transform 10 | import statmorph 11 | from astropy.visualization import simple_norm 12 | 13 | __all__ = ['make_figure'] 14 | 15 | def _get_ax(fig, row, col, nrows, ncols, wpanel, hpanel, htop, eps, wfig, hfig): 16 | x_ax = (col+1)*eps + col*wpanel 17 | y_ax = eps + (nrows-1-row)*(hpanel+htop) 18 | return fig.add_axes([x_ax/wfig, y_ax/hfig, wpanel/wfig, hpanel/hfig]) 19 | 20 | def make_figure(morph): 21 | """ 22 | Creates a figure analogous to Fig. 4 from Rodriguez-Gomez et al. (2019) 23 | for a given ``SourceMorphology`` object. 24 | 25 | Parameters 26 | ---------- 27 | morph : ``statmorph.SourceMorphology`` 28 | An object containing the morphological measurements of a single 29 | source. 30 | 31 | Returns 32 | ------- 33 | fig : ``matplotlib.figure.Figure`` 34 | The figure. 35 | 36 | """ 37 | import matplotlib.pyplot as plt 38 | import matplotlib.colors 39 | import matplotlib.cm 40 | 41 | if not isinstance(morph, statmorph.SourceMorphology): 42 | raise TypeError('Input must be of type SourceMorphology.') 43 | 44 | if morph.flag == 4: 45 | raise Exception('Catastrophic flag (not worth plotting)') 46 | 47 | # I'm tired of dealing with plt.add_subplot, plt.subplots, plg.GridSpec, 48 | # plt.subplot2grid, etc. and never getting the vertical and horizontal 49 | # inter-panel spacings to have the same size, so instead let's do 50 | # everything manually: 51 | nrows = 2 52 | ncols = 4 53 | wpanel = 4.0 # panel width 54 | hpanel = 4.0 # panel height 55 | htop = 0.05*nrows*hpanel # top margin and vertical space between panels 56 | eps = 0.005*nrows*hpanel # all other margins 57 | wfig = ncols*wpanel + (ncols+1)*eps # total figure width 58 | hfig = nrows*(hpanel+htop) + eps # total figure height 59 | fig = plt.figure(figsize=(wfig, hfig)) 60 | 61 | # For drawing circles/ellipses 62 | theta_vec = np.linspace(0.0, 2.0*np.pi, 200) 63 | 64 | # Add black to pastel colormap 65 | cmap_orig = matplotlib.cm.Pastel1 66 | colors = ((0.0, 0.0, 0.0), *cmap_orig.colors) 67 | cmap = matplotlib.colors.ListedColormap(colors) 68 | 69 | # Get some general info about the image 70 | image = np.float64(morph._cutout_stamp_maskzeroed) # skimage wants double 71 | ny, nx = image.shape 72 | xc, yc = morph._xc_stamp, morph._yc_stamp # centroid 73 | xca, yca = morph._asymmetry_center # asym. center 74 | xcs, ycs = morph._sersic_model.x_0.value, morph._sersic_model.y_0.value # Sersic center 75 | 76 | ################## 77 | # Original image # 78 | ################## 79 | ax = _get_ax(fig, 0, 0, nrows, ncols, wpanel, hpanel, htop, eps, wfig, hfig) 80 | ax.imshow(image, cmap='gray', origin='lower', 81 | norm=simple_norm(image, stretch='log', log_a=10000)) 82 | ax.plot(xc, yc, 'go', markersize=5, label='Centroid') 83 | R = np.sqrt(nx**2 + ny**2) 84 | theta = morph.orientation_centroid 85 | x0, x1 = xc - R*np.cos(theta), xc + R*np.cos(theta) 86 | y0, y1 = yc - R*np.sin(theta), yc + R*np.sin(theta) 87 | ax.plot([x0, x1], [y0, y1], 'g--', lw=1.5, label='Major Axis (Centroid)') 88 | ax.plot(xca, yca, 'bo', markersize=5, label='Asym. Center') 89 | R = np.sqrt(nx**2 + ny**2) 90 | theta = morph.orientation_asymmetry 91 | x0, x1 = xca - R*np.cos(theta), xca + R*np.cos(theta) 92 | y0, y1 = yca - R*np.sin(theta), yca + R*np.sin(theta) 93 | ax.plot([x0, x1], [y0, y1], 'b--', lw=1.5, label='Major Axis (Asym.)') 94 | # Half-radius ellipse 95 | a = morph.rhalf_ellip 96 | b = a / morph.elongation_asymmetry 97 | theta = morph.orientation_asymmetry 98 | xprime, yprime = a*np.cos(theta_vec), b*np.sin(theta_vec) 99 | x = xca + (xprime*np.cos(theta) - yprime*np.sin(theta)) 100 | y = yca + (xprime*np.sin(theta) + yprime*np.cos(theta)) 101 | ax.plot(x, y, 'b', label='Half-Light Ellipse') 102 | # Some text 103 | text = 'flag = %d\nEllip. (Centroid) = %.4f\nEllip. (Asym.) = %.4f' % ( 104 | morph.flag, morph.ellipticity_centroid, morph.ellipticity_asymmetry) 105 | ax.text(0.034, 0.966, text, 106 | horizontalalignment='left', verticalalignment='top', 107 | transform=ax.transAxes, 108 | bbox=dict(facecolor='white', alpha=1.0, boxstyle='round')) 109 | # Finish plot 110 | ax.legend(loc=4, fontsize=12, facecolor='w', framealpha=1.0, edgecolor='k') 111 | ax.set_xlim(-0.5, nx-0.5) 112 | ax.set_ylim(-0.5, ny-0.5) 113 | ax.set_title('Original Image (Log Stretch)', fontsize=14) 114 | ax.get_xaxis().set_visible(False) 115 | ax.get_yaxis().set_visible(False) 116 | 117 | ############## 118 | # Sersic fit # 119 | ############## 120 | ax = _get_ax(fig, 0, 1, nrows, ncols, wpanel, hpanel, htop, eps, wfig, hfig) 121 | y, x = np.mgrid[0:ny, 0:nx] 122 | sersic_model = morph._sersic_model(x, y) 123 | # Add background noise (for realism) 124 | if morph.sky_sigma > 0: 125 | sersic_model += np.random.normal(scale=morph.sky_sigma, size=(ny, nx)) 126 | ax.imshow(sersic_model, cmap='gray', origin='lower', 127 | norm=simple_norm(image, stretch='log', log_a=10000)) 128 | ax.plot(xcs, ycs, 'ro', markersize=5, label='Sérsic Center') 129 | R = np.sqrt(nx**2 + ny**2) 130 | theta = morph.sersic_theta 131 | x0, x1 = xcs - R*np.cos(theta), xcs + R*np.cos(theta) 132 | y0, y1 = ycs - R*np.sin(theta), ycs + R*np.sin(theta) 133 | ax.plot([x0, x1], [y0, y1], 'r--', lw=1.5, label='Major Axis (Sérsic)') 134 | # Half-radius ellipse 135 | a = morph.sersic_rhalf 136 | b = a * (1.0 - morph.sersic_ellip) 137 | xprime, yprime = a*np.cos(theta_vec), b*np.sin(theta_vec) 138 | x = xcs + (xprime*np.cos(theta) - yprime*np.sin(theta)) 139 | y = ycs + (xprime*np.sin(theta) + yprime*np.cos(theta)) 140 | ax.plot(x, y, 'r', label='Half-Light Ellipse (Sérsic)') 141 | # Some text 142 | text = ('flag_sersic = %d' % (morph.flag_sersic,) + '\n' + 143 | 'Ellip. (Sérsic) = %.4f' % (morph.sersic_ellip,) + '\n' + 144 | r'$n = %.4f$' % (morph.sersic_n,)) 145 | ax.text(0.034, 0.966, text, 146 | horizontalalignment='left', verticalalignment='top', 147 | transform=ax.transAxes, 148 | bbox=dict(facecolor='white', alpha=1.0, boxstyle='round')) 149 | # Finish plot 150 | ax.legend(loc=4, fontsize=12, facecolor='w', framealpha=1.0, edgecolor='k') 151 | ax.set_title('Sérsic Model + Noise', fontsize=14) 152 | ax.set_xlim(-0.5, nx-0.5) 153 | ax.set_ylim(-0.5, ny-0.5) 154 | ax.get_xaxis().set_visible(False) 155 | ax.get_yaxis().set_visible(False) 156 | 157 | ################### 158 | # Sersic residual # 159 | ################### 160 | ax = _get_ax(fig, 0, 2, nrows, ncols, wpanel, hpanel, htop, eps, wfig, hfig) 161 | y, x = np.mgrid[0:ny, 0:nx] 162 | sersic_res = morph._cutout_stamp_maskzeroed - morph._sersic_model(x, y) 163 | sersic_res[morph._mask_stamp] = 0.0 164 | ax.imshow(sersic_res, cmap='gray', origin='lower', 165 | norm=simple_norm(sersic_res, stretch='linear')) 166 | ax.set_title('Sérsic Residual, ' + r'$I - I_{\rm model}$', fontsize=14) 167 | ax.set_xlim(-0.5, nx-0.5) 168 | ax.set_ylim(-0.5, ny-0.5) 169 | ax.get_xaxis().set_visible(False) 170 | ax.get_yaxis().set_visible(False) 171 | 172 | ###################### 173 | # Asymmetry residual # 174 | ###################### 175 | ax = _get_ax(fig, 0, 3, nrows, ncols, wpanel, hpanel, htop, eps, wfig, hfig) 176 | # Rotate image around asym. center 177 | # (note that skimage expects pixel positions at lower-left corners) 178 | image_180 = skimage.transform.rotate(image, 180.0, center=(xca, yca)) 179 | image_res = image - image_180 180 | # Apply symmetric mask 181 | mask = morph._mask_stamp.copy() 182 | mask_180 = skimage.transform.rotate(mask, 180.0, center=(xca, yca)) 183 | mask_180 = mask_180 >= 0.5 # convert back to bool 184 | mask_symmetric = mask | mask_180 185 | image_res = np.where(~mask_symmetric, image_res, 0.0) 186 | ax.imshow(image_res, cmap='gray', origin='lower', 187 | norm=simple_norm(image_res, stretch='linear')) 188 | ax.set_title('Asymmetry Residual, ' + r'$I - I_{180}$', fontsize=14) 189 | ax.set_xlim(-0.5, nx-0.5) 190 | ax.set_ylim(-0.5, ny-0.5) 191 | ax.get_xaxis().set_visible(False) 192 | ax.get_yaxis().set_visible(False) 193 | 194 | ################### 195 | # Original segmap # 196 | ################### 197 | ax = _get_ax(fig, 1, 0, nrows, ncols, wpanel, hpanel, htop, eps, wfig, hfig) 198 | ax.imshow(image, cmap='gray', origin='lower', 199 | norm=simple_norm(image, stretch='log', log_a=10000)) 200 | # Show original segmap 201 | contour_levels = [0.5] 202 | contour_colors = [(0, 0, 0)] 203 | segmap_stamp = morph._segmap.data[morph._slice_stamp] 204 | Z = np.float64(segmap_stamp == morph.label) 205 | ax.contour(Z, contour_levels, colors=contour_colors, linewidths=1.5) 206 | # Show skybox 207 | xmin = morph._slice_skybox[1].start 208 | ymin = morph._slice_skybox[0].start 209 | xmax = morph._slice_skybox[1].stop - 1 210 | ymax = morph._slice_skybox[0].stop - 1 211 | ax.plot(np.array([xmin, xmax, xmax, xmin, xmin]), 212 | np.array([ymin, ymin, ymax, ymax, ymin]), 213 | 'b', lw=1.5, label='Skybox') 214 | # Some text 215 | text = ('Sky Mean = %.4e' % (morph.sky_mean,) + '\n' + 216 | 'Sky Median = %.4e' % (morph.sky_median,) + '\n' + 217 | 'Sky Sigma = %.4e' % (morph.sky_sigma,)) 218 | ax.text(0.034, 0.966, text, 219 | horizontalalignment='left', verticalalignment='top', 220 | transform=ax.transAxes, 221 | bbox=dict(facecolor='white', alpha=1.0, boxstyle='round')) 222 | # Finish plot 223 | ax.set_xlim(-0.5, nx-0.5) 224 | ax.set_ylim(-0.5, ny-0.5) 225 | ax.legend(loc=4, fontsize=12, facecolor='w', framealpha=1.0, edgecolor='k') 226 | ax.set_title('Original Segmap', fontsize=14) 227 | ax.get_xaxis().set_visible(False) 228 | ax.get_yaxis().set_visible(False) 229 | 230 | ############### 231 | # Gini segmap # 232 | ############### 233 | ax = _get_ax(fig, 1, 1, nrows, ncols, wpanel, hpanel, htop, eps, wfig, hfig) 234 | ax.imshow(image, cmap='gray', origin='lower', 235 | norm=simple_norm(image, stretch='log', log_a=10000)) 236 | # Show Gini segmap 237 | contour_levels = [0.5] 238 | contour_colors = [(0, 0, 0)] 239 | Z = np.float64(morph._segmap_gini) 240 | ax.contour(Z, contour_levels, colors=contour_colors, linewidths=1.5) 241 | # Some text 242 | text = r'$\left\langle {\rm S/N} \right\rangle = %.4f$' % (morph.sn_per_pixel,) 243 | ax.text(0.034, 0.966, text, fontsize=12, 244 | horizontalalignment='left', verticalalignment='top', 245 | transform=ax.transAxes, 246 | bbox=dict(facecolor='white', alpha=1.0, boxstyle='round')) 247 | text = (r'$G = %.4f$' % (morph.gini,) + '\n' + 248 | r'$M_{20} = %.4f$' % (morph.m20,) + '\n' + 249 | r'$F(G, M_{20}) = %.4f$' % (morph.gini_m20_bulge,) + '\n' + 250 | r'$S(G, M_{20}) = %.4f$' % (morph.gini_m20_merger,)) 251 | ax.text(0.034, 0.034, text, fontsize=12, 252 | horizontalalignment='left', verticalalignment='bottom', 253 | transform=ax.transAxes, 254 | bbox=dict(facecolor='white', alpha=1.0, boxstyle='round')) 255 | text = (r'$C = %.4f$' % (morph.concentration,) + '\n' + 256 | r'$A = %.4f$' % (morph.asymmetry,) + '\n' + 257 | r'$S = %.4f$' % (morph.smoothness,)) 258 | ax.text(0.966, 0.034, text, fontsize=12, 259 | horizontalalignment='right', verticalalignment='bottom', 260 | transform=ax.transAxes, 261 | bbox=dict(facecolor='white', alpha=1.0, boxstyle='round')) 262 | # Finish plot 263 | ax.set_xlim(-0.5, nx-0.5) 264 | ax.set_ylim(-0.5, ny-0.5) 265 | ax.set_title('Gini Segmap', fontsize=14) 266 | ax.get_xaxis().set_visible(False) 267 | ax.get_yaxis().set_visible(False) 268 | 269 | #################### 270 | # Watershed segmap # 271 | #################### 272 | ax = _get_ax(fig, 1, 2, nrows, ncols, wpanel, hpanel, htop, eps, wfig, hfig) 273 | labeled_array, peak_labels, xpeak, ypeak = morph._watershed_mid 274 | labeled_array_plot = (labeled_array % (cmap.N-1)) + 1 275 | labeled_array_plot[labeled_array == 0] = 0.0 # background is black 276 | ax.imshow(labeled_array_plot, cmap=cmap, origin='lower', 277 | norm=matplotlib.colors.NoNorm()) 278 | sorted_flux_sums, sorted_xpeak, sorted_ypeak = morph._intensity_sums 279 | if len(sorted_flux_sums) > 0: 280 | ax.plot(sorted_xpeak[0], sorted_ypeak[0], 'bo', markersize=2, 281 | label='First Peak') 282 | if len(sorted_flux_sums) > 1: 283 | ax.plot(sorted_xpeak[1], sorted_ypeak[1], 'ro', markersize=2, 284 | label='Second Peak') 285 | # Some text 286 | text = (r'$M = %.4f$' % (morph.multimode,) + '\n' + 287 | r'$I = %.4f$' % (morph.intensity,) + '\n' + 288 | r'$D = %.4f$' % (morph.deviation,)) 289 | ax.text(0.034, 0.034, text, fontsize=12, 290 | horizontalalignment='left', verticalalignment='bottom', 291 | transform=ax.transAxes, 292 | bbox=dict(facecolor='white', alpha=1.0, boxstyle='round')) 293 | ax.legend(loc=4, fontsize=12, facecolor='w', framealpha=1.0, edgecolor='k') 294 | ax.set_title('Watershed Segmap (' + r'$I$' + ' statistic)', fontsize=14) 295 | ax.set_xlim(-0.5, nx-0.5) 296 | ax.set_ylim(-0.5, ny-0.5) 297 | ax.get_xaxis().set_visible(False) 298 | ax.get_yaxis().set_visible(False) 299 | 300 | ########################## 301 | # Shape asymmetry segmap # 302 | ########################## 303 | ax = _get_ax(fig, 1, 3, nrows, ncols, wpanel, hpanel, htop, eps, wfig, hfig) 304 | ax.imshow(morph._segmap_shape_asym, cmap='gray', origin='lower') 305 | ax.plot(xca, yca, 'bo', markersize=5, label='Asym. Center') 306 | r = morph.rpetro_circ 307 | ax.plot(xca + r*np.cos(theta_vec), yca + r*np.sin(theta_vec), 'b', 308 | label=r'$r_{\rm petro, circ}$') 309 | r = morph.rpetro_ellip 310 | ax.plot(xca + r*np.cos(theta_vec), yca + r*np.sin(theta_vec), 'r', 311 | label=r'$r_{\rm petro, ellip}$') 312 | r = morph.rmax_circ 313 | ax.plot(xca + r*np.cos(theta_vec), 314 | yca + r*np.sin(theta_vec), 315 | 'c', lw=1.5, label=r'$r_{\rm max}$') 316 | text = (r'$A_S = %.4f$' % (morph.shape_asymmetry,)) 317 | ax.text(0.034, 0.034, text, fontsize=12, 318 | horizontalalignment='left', verticalalignment='bottom', 319 | transform=ax.transAxes, 320 | bbox=dict(facecolor='white', alpha=1.0, boxstyle='round')) 321 | ax.legend(loc=4, fontsize=12, facecolor='w', framealpha=1.0, edgecolor='k') 322 | ax.set_xlim(-0.5, nx-0.5) 323 | ax.set_ylim(-0.5, ny-0.5) 324 | ax.set_title('Shape Asymmetry Segmap', fontsize=14) 325 | ax.get_xaxis().set_visible(False) 326 | ax.get_yaxis().set_visible(False) 327 | 328 | fig.subplots_adjust(left=eps/wfig, right=1-eps/wfig, bottom=eps/hfig, 329 | top=1.0-htop/hfig, wspace=eps/wfig, hspace=htop/hfig) 330 | 331 | return fig 332 | -------------------------------------------------------------------------------- /statmorph/tests/test_statmorph.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the statmorph morphology package. 3 | """ 4 | # Author: Vicente Rodriguez-Gomez 5 | # Licensed under a 3-Clause BSD License. 6 | import numpy as np 7 | import os 8 | import pytest 9 | import statmorph 10 | from astropy.modeling.models import Sersic2D 11 | from astropy.io import fits 12 | from astropy.utils.exceptions import AstropyUserWarning 13 | from numpy.testing import assert_allclose 14 | 15 | __all__ = ['runall'] 16 | 17 | 18 | def test_quantile(): 19 | from statmorph.statmorph import _quantile 20 | quantiles = np.linspace(0, 1, 11) 21 | data = np.arange(25, dtype=np.float64)**2 22 | # Compare with np.percentile (note that _quantile() assumes that the 23 | # input array is already sorted, so it's much faster in these cases). 24 | res1 = []; res2 = [] 25 | for q in quantiles: 26 | res1.append(_quantile(data, q)) 27 | res2.append(np.percentile(data, 100*q, method='lower')) 28 | assert_allclose(res1, res2) 29 | # Check out-of-range input. 30 | with pytest.raises(ValueError): 31 | _ = _quantile(data, -0.5) 32 | _ = _quantile(data, 1.5) 33 | 34 | 35 | def test_convolved_sersic(): 36 | from scipy.signal import fftconvolve 37 | from astropy.convolution import Gaussian2DKernel 38 | # Create Gaussian PSF. 39 | kernel = Gaussian2DKernel(2.0) 40 | kernel.normalize() # make sure kernel adds up to 1 41 | psf = kernel.array # we only need the numpy array 42 | # Create 2D Sersic profile. 43 | ny, nx = 25, 25 44 | y, x = np.mgrid[0:ny, 0:nx] 45 | sersic = Sersic2D( 46 | amplitude=1, r_eff=5, n=1.5, x_0=12, y_0=12, ellip=0.5, theta=0) 47 | z = sersic(x, y) 48 | # Create "convolved" Sersic profile with same properties as normal one. 49 | convolved_sersic = statmorph.ConvolvedSersic2D( 50 | amplitude=1, r_eff=5, n=1.5, x_0=12, y_0=12, ellip=0.5, theta=0) 51 | with pytest.raises(AssertionError): 52 | _ = convolved_sersic(x, y) # PSF not set yet 53 | convolved_sersic.set_psf(psf) 54 | z_convolved = convolved_sersic(x, y) 55 | # Compare results. 56 | assert_allclose(z_convolved, fftconvolve(z, psf, mode='same')) 57 | 58 | 59 | def test_missing_arguments(): 60 | label = 1 61 | image = np.ones((3, 3), dtype=np.float64) 62 | segmap = np.ones((3, 3), dtype=np.int64) 63 | with pytest.raises(AssertionError): 64 | _ = statmorph.SourceMorphology(image, segmap, label) 65 | 66 | 67 | def test_catastrophic(): 68 | label = 1 69 | image = np.full((3, 3), -1.0, dtype=np.float64) 70 | segmap = np.full((3, 3), label, dtype=np.int64) 71 | with pytest.warns() as w: 72 | morph = statmorph.SourceMorphology(image, segmap, label, gain=1.0) 73 | assert len(w) == 1 74 | assert w[0].category == AstropyUserWarning 75 | assert 'Total flux is nonpositive.' in str(w[0].message) 76 | assert morph.flag == 4 77 | 78 | 79 | def test_masked_centroid(): 80 | label = 1 81 | ny, nx = 11, 11 82 | y, x = np.mgrid[0:ny, 0:nx] 83 | r = np.sqrt((x - nx//2)**2 + (y - ny//2)**2) 84 | image = np.exp(-r**2) 85 | segmap = np.int64(image > 1e-3) 86 | mask = np.zeros((ny, nx), dtype=np.bool_) 87 | mask[r < 2] = True 88 | with pytest.warns() as w: 89 | morph = statmorph.SourceMorphology(image, segmap, label, gain=1.0, 90 | mask=mask) 91 | assert w[0].category == AstropyUserWarning 92 | assert 'Centroid is masked.' in str(w[0].message) 93 | assert morph.flag == 2 94 | 95 | 96 | def test_bright_pixel(): 97 | """ 98 | Test bright pixel outside of main segment. Note that 99 | we do not remove outliers. 100 | """ 101 | label = 1 102 | ny, nx = 11, 11 103 | y, x = np.mgrid[0:ny, 0:nx] 104 | image = np.exp(-(x - 5) ** 2 - (y - 5) ** 2) 105 | image[7, 7] = 1.0 106 | segmap = np.int64(image > 1e-3) 107 | segmap[5, 5] = 0 108 | with pytest.warns() as w: 109 | morph = statmorph.SourceMorphology(image, segmap, label, gain=1.0, 110 | n_sigma_outlier=-1) 111 | assert w[0].category == AstropyUserWarning 112 | assert 'Adding brightest pixel to segmap.' in str(w[0].message) 113 | assert morph.flag == 2 114 | 115 | 116 | def test_negative_source(): 117 | label = 1 118 | ny, nx = 51, 51 119 | y, x = np.mgrid[0:ny, 0:nx] 120 | r = np.sqrt((x - nx//2)**2 + (y - ny//2)**2) 121 | image = np.ones((ny, nx), dtype=np.float64) 122 | locs = r > 0 123 | image[locs] = 5.0/r[locs] - 1.0 124 | segmap = np.int64(r < 2) 125 | with pytest.warns() as w: 126 | morph = statmorph.SourceMorphology(image, segmap, label, gain=1.0) 127 | assert w[0].category == AstropyUserWarning 128 | assert 'Total flux sum is negative.' in str(w[0].message) 129 | assert morph.flag == 2 130 | 131 | 132 | def test_tiny_source(): 133 | """ 134 | Test tiny source (actually consisting of a single bright pixel). 135 | Note that we do not remove outliers. 136 | """ 137 | label = 1 138 | image = np.zeros((5, 5), dtype=np.float64) 139 | image[2, 2] = 1.0 140 | segmap = np.int64(image) 141 | with pytest.warns() as w: 142 | morph = statmorph.SourceMorphology(image, segmap, label, gain=1.0, 143 | n_sigma_outlier=-1) 144 | assert w[0].category == AstropyUserWarning 145 | assert 'Nonpositive second moment.' in str(w[0].message) 146 | assert morph.flag == 2 147 | 148 | 149 | def test_insufficient_data(): 150 | """ 151 | Test insufficient data for Sersic fit (< 7 pixels). 152 | Note that we do not remove outliers. 153 | """ 154 | label = 1 155 | image = np.zeros((2, 3), dtype=np.float64) 156 | image[:, 1] = 1.0 157 | segmap = np.int64(image) 158 | with pytest.warns() as w: 159 | morph = statmorph.SourceMorphology(image, segmap, label, gain=1.0, 160 | n_sigma_outlier=-1) 161 | assert w[-2].category == AstropyUserWarning 162 | assert 'Not enough data for fit.' in str(w[-2].message) 163 | assert morph.flag == 2 164 | 165 | 166 | def test_asymmetric(): 167 | """ 168 | Test a case in which the asymmetry center is pushed outside of 169 | the image boundaries. 170 | """ 171 | label = 1 172 | y, x = np.mgrid[0:25, 0:25] 173 | image = x - 20 174 | segmap = np.int64(image > 0) 175 | with pytest.warns() as w: 176 | morph = statmorph.SourceMorphology(image, segmap, label, gain=1.0) 177 | assert w[0].category == AstropyUserWarning 178 | assert 'Minimizer tried to exit bounds.' in str(w[0].message) 179 | assert morph.flag == 2 180 | 181 | 182 | def test_small_source(): 183 | np.random.seed(1) 184 | ny, nx = 11, 11 185 | y, x = np.mgrid[0:ny, 0:nx] 186 | image = np.exp(-(x - 5) ** 2 - (y - 5) ** 2) 187 | image += 0.001 * np.random.standard_normal(size=(ny, nx)) 188 | segmap = np.int64(image > 0.1) 189 | label = 1 190 | with pytest.warns() as w: 191 | morph = statmorph.SourceMorphology(image, segmap, label, gain=1.0, 192 | verbose=True) 193 | assert w[-1].category == AstropyUserWarning 194 | assert 'Single clump!' in str(w[-1].message) 195 | assert morph.flag == 0 196 | assert morph.multimode == 0 197 | assert morph.intensity == 0 198 | 199 | 200 | def test_full_segmap(): 201 | ny, nx = 11, 11 202 | y, x = np.mgrid[0:ny, 0:nx] 203 | image = np.exp(-(x - 5) ** 2 - (y - 5) ** 2) 204 | segmap = np.ones((ny, nx), dtype=np.int64) 205 | label = 1 206 | with pytest.warns() as w: 207 | morph = statmorph.SourceMorphology(image, segmap, label, gain=1.0, 208 | verbose=True) 209 | assert w[-1].category == AstropyUserWarning 210 | assert 'Image is not background-subtracted.' in str(w[-1].message) 211 | assert morph.flag == 2 212 | assert morph._slice_skybox == (slice(0, 0), slice(0, 0)) 213 | 214 | 215 | def test_random_noise(): 216 | np.random.seed(1) 217 | ny, nx = 11, 11 218 | image = 0.1 * np.random.standard_normal(size=(ny, nx)) 219 | weightmap = 0.01 * np.random.standard_normal(size=(ny, nx)) 220 | segmap = np.ones((ny, nx), dtype=np.int64) 221 | label = 1 222 | with pytest.warns() as w: 223 | morph = statmorph.SourceMorphology(image, segmap, label, 224 | weightmap=weightmap) 225 | assert w[-1].category == AstropyUserWarning 226 | assert morph.flag == 2 227 | 228 | # Note (2024/11/19): with the latest changes, not sure how to get 229 | # statmorph to generate an "empty" segmap from scratch, so the 230 | # following test is disabled for now. 231 | # def test_empty_gini_segmap(): 232 | # """ 233 | # This pathological case results in an "empty" Gini segmap. 234 | # """ 235 | # label = 1 236 | # np.random.seed(0) 237 | # ny, nx = 11, 11 238 | # y, x = np.mgrid[0:ny, 0:nx] 239 | # image = x - 9.0 240 | # segmap = np.int64(image > 0) 241 | # image += 0.1 * np.random.standard_normal(size=(ny, nx)) 242 | # with pytest.warns() as w: 243 | # morph = statmorph.SourceMorphology(image, segmap, label, gain=1.0) 244 | # assert w[-1].category == AstropyUserWarning 245 | # assert 'Segmaps are empty!' in str(w[-1].message) 246 | # assert morph.flag == 2 247 | 248 | 249 | def test_full_gini_segmap(): 250 | """ 251 | This produces a "full" Gini segmap. 252 | """ 253 | label = 1 254 | ny, nx = 11, 11 255 | y, x = np.mgrid[0:ny, 0:nx] 256 | image = np.exp(-((x - nx // 2) ** 2 + (y - ny // 2) ** 2) / 50) 257 | segmap = np.int64(image > 0.5) 258 | with pytest.warns() as w: 259 | morph = statmorph.SourceMorphology(image, segmap, label, gain=1.0) 260 | assert w[2].category == AstropyUserWarning 261 | assert 'Full Gini segmap!' in str(w[2].message) 262 | assert morph.flag == 2 263 | 264 | 265 | def test_merger(): 266 | """ 267 | Test a "merger" scenario. This manages to produce different Gini 268 | and MID segmaps, as well as a failed Sersic fit. 269 | """ 270 | label = 1 271 | ny, nx = 25, 25 272 | y, x = np.mgrid[0:ny, 0:nx] 273 | image = np.exp(-(x-8)**2/4 - (y-12)**2) 274 | image += np.exp(-(x-16)**2/4 - (y-12)**2) 275 | segmap = np.int64(np.abs(image) > 1e-3) 276 | with pytest.warns() as w: 277 | morph = statmorph.SourceMorphology( 278 | image, segmap, label, gain=1.0, verbose=True) 279 | assert w[-1].category == AstropyUserWarning 280 | assert 'Gini and MID segmaps are quite different.' in str(w[-1].message) 281 | assert morph.flag == 1 282 | 283 | 284 | class TestSourceMorphology(object): 285 | """ 286 | Check measurements for a test galaxy image + segmap + mask. 287 | """ 288 | def setup_class(self): 289 | self.correct_values = { 290 | 'xc_centroid': 81.66578971349672, 291 | 'yc_centroid': 80.53461797343211, 292 | 'ellipticity_centroid': 0.04855709579576, 293 | 'elongation_centroid': 1.05103521775316, 294 | 'orientation_centroid': -0.85797602066195, 295 | 'xc_asymmetry': 82.23985214801982, 296 | 'yc_asymmetry': 80.76076242700849, 297 | 'ellipticity_asymmetry': 0.04806946962244, 298 | 'elongation_asymmetry': 1.05049682522881, 299 | 'orientation_asymmetry': -0.85405676626920, 300 | 'flux_circ': 5758942.65115976985544, 301 | 'flux_ellip': 5758313.01348320022225, 302 | 'rpetro_circ': 40.93755531944313, 303 | 'rpetro_ellip': 41.64283484446126, 304 | 'rmax_circ': 54.10691995800065, 305 | 'rmax_ellip': 54.57312319389109, 306 | 'rhalf_circ': 21.60803205322342, 307 | 'rhalf_ellip': 22.08125638365687, 308 | 'r20': 11.69548630967248, 309 | 'r50': 21.62164455681452, 310 | 'r80': 32.07883340820674, 311 | 'gini': 0.38993180299621, 312 | 'm20': -1.54448930789228, 313 | 'gini_m20_bulge': -0.95950648479940, 314 | 'gini_m20_merger': -0.15565152883078, 315 | 'sn_per_pixel': 6.80319166183472, 316 | 'concentration': 2.19100140632153, 317 | 'asymmetry': 0.00377345808887, 318 | 'smoothness': 0.00430880839402, 319 | 'multimode': 0.23423423423423, 320 | 'intensity': 0.51203949030140, 321 | 'deviation': 0.01522525597953, 322 | 'rms_asymmetry2': 0.0053900924481633, 323 | 'outer_asymmetry': -0.01821399684443, 324 | 'shape_asymmetry': 0.16308278287864, 325 | 'sersic_amplitude': 1296.95288208155739, 326 | 'sersic_rhalf': 22.45788866502031, 327 | 'sersic_n': 0.61206828194077, 328 | 'sersic_xc': 81.56197595338546, 329 | 'sersic_yc': 80.40465135599014, 330 | 'sersic_ellip': 0.05083866217150, 331 | 'sersic_theta': 2.47831542907976, 332 | 'sersic_chi2_dof': 1.3376238276749, 333 | 'sersic_aic': 7640.9506975345, 334 | 'sersic_bic': 7698.176779495, 335 | 'doublesersic_xc': 81.833668324998, 336 | 'doublesersic_yc': 80.569118306697, 337 | 'doublesersic_amplitude1': 538.85377533706, 338 | 'doublesersic_rhalf1': 9.3163040096374, 339 | 'doublesersic_n1': 1.3067599434903, 340 | 'doublesersic_ellip1': 0.15917549351885, 341 | 'doublesersic_theta1': 0.76239395305723, 342 | 'doublesersic_amplitude2': 1262.1734747316, 343 | 'doublesersic_rhalf2': 23.477198713554, 344 | 'doublesersic_n2': 0.36682254527453, 345 | 'doublesersic_ellip2': 0.06669592350554, 346 | 'doublesersic_theta2': 2.4997429173919, 347 | 'doublesersic_chi2_dof': 1.1574114573895, 348 | 'doublesersic_aic': 3848.3566991179, 349 | 'doublesersic_bic': 3946.4585539074, 350 | 'sky_mean': 3.48760604858398, 351 | 'sky_median': -2.68543863296509, 352 | 'sky_sigma': 150.91754150390625, 353 | 'xmin_stamp': 0, 354 | 'ymin_stamp': 0, 355 | 'xmax_stamp': 161, 356 | 'ymax_stamp': 161, 357 | 'nx_stamp': 162, 358 | 'ny_stamp': 162, 359 | 'flag': 0, 360 | 'flag_sersic': 0, 361 | 'flag_doublesersic': 0, 362 | } 363 | 364 | # Run statmorph on the same galaxy from which the above values 365 | # were obtained. 366 | curdir = os.path.dirname(__file__) 367 | with fits.open('%s/data/slice.fits' % (curdir,)) as hdulist: 368 | self.image = hdulist[0].data 369 | self.segmap = hdulist[1].data 370 | self.mask = np.bool_(hdulist[2].data) 371 | self.gain = 1.0 372 | 373 | def test_no_psf(self, print_values=False): 374 | source_morphs = statmorph.source_morphology( 375 | self.image, self.segmap, mask=self.mask, gain=self.gain, 376 | include_doublesersic=True) 377 | morph = source_morphs[0] 378 | for key in self.correct_values: 379 | assert_allclose(morph[key], self.correct_values[key], rtol=1e-5, 380 | err_msg="%s value did not match." % (key,)) 381 | if print_values: 382 | print("'%s': %.14g," % (key, morph[key])) 383 | 384 | def test_psf(self): 385 | # Try delta-like PSF, which should return approximately (since we are 386 | # using fftconvolve instead of convolve) the same results as no PSF. 387 | psf = np.array([[0, 0, 0], 388 | [0, 1, 0], 389 | [0, 0, 0]], dtype=np.float64) 390 | source_morphs = statmorph.source_morphology( 391 | self.image, self.segmap, mask=self.mask, gain=self.gain, psf=psf, 392 | include_doublesersic=True) 393 | morph = source_morphs[0] 394 | for key in self.correct_values: 395 | assert_allclose(morph[key], self.correct_values[key], rtol=1e-5, 396 | err_msg="%s value did not match." % (key,)) 397 | 398 | def test_weightmap(self): 399 | # Manually create weight map instead of using the gain argument. 400 | weightmap = np.sqrt( 401 | np.abs(self.image) / self.gain + self.correct_values['sky_sigma']**2) 402 | source_morphs = statmorph.source_morphology( 403 | self.image, self.segmap, mask=self.mask, weightmap=weightmap, 404 | include_doublesersic=True) 405 | morph = source_morphs[0] 406 | for key in self.correct_values: 407 | assert_allclose(morph[key], self.correct_values[key], rtol=1e-5, 408 | err_msg="%s value did not match." % (key,)) 409 | 410 | 411 | def runall(print_values=False): 412 | """ 413 | Run the most basic tests. Keep this function for backward compatibility. 414 | """ 415 | test = TestSourceMorphology() 416 | test.setup_class() 417 | test.test_no_psf(print_values=print_values) 418 | -------------------------------------------------------------------------------- /docs/notebooks/doublesersic.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "## Double 2D Sersic example\n", 8 | "\n", 9 | "In this example we create a (simplified) synthetic galaxy image consisting of two Sersic components, add some \"realism\" to it (PSF + noise), and then run statmorph in order to recover the parameters of the two components." 10 | ] 11 | }, 12 | { 13 | "cell_type": "code", 14 | "execution_count": null, 15 | "metadata": {}, 16 | "outputs": [], 17 | "source": [ 18 | "import numpy as np\n", 19 | "import matplotlib.pyplot as plt\n", 20 | "from astropy.visualization import simple_norm\n", 21 | "from astropy.modeling.models import Sersic2D\n", 22 | "from astropy.convolution import convolve, Gaussian2DKernel\n", 23 | "from photutils.segmentation import detect_threshold, detect_sources\n", 24 | "import time\n", 25 | "import statmorph\n", 26 | "%matplotlib inline" 27 | ] 28 | }, 29 | { 30 | "cell_type": "markdown", 31 | "metadata": {}, 32 | "source": [ 33 | "### Setting up\n", 34 | "\n", 35 | "**Creating the model galaxy image**\n", 36 | "\n", 37 | "We assume that the image size is 240x240 pixels and that the \"true\" light distribution corresponds to a *double* 2D Sersic model with the following parameters (note that the two components share the same center, by construction):" 38 | ] 39 | }, 40 | { 41 | "cell_type": "code", 42 | "execution_count": null, 43 | "metadata": {}, 44 | "outputs": [], 45 | "source": [ 46 | "ny, nx = 240, 240\n", 47 | "y, x = np.mgrid[0:ny, 0:nx]\n", 48 | "doublesersic_model = statmorph.DoubleSersic2D(\n", 49 | " x_0=120.5, y_0=96.5,\n", 50 | " amplitude_1=1, r_eff_1=10, n_1=5.0, ellip_1=0.6, theta_1=2.0,\n", 51 | " amplitude_2=2, r_eff_2=20, n_2=1.0, ellip_2=0.4, theta_2=0.5)\n", 52 | "image = doublesersic_model(x, y)\n", 53 | "\n", 54 | "# Visualize \"idealized\" image\n", 55 | "plt.imshow(image, cmap='gray', origin='lower',\n", 56 | " norm=simple_norm(image, stretch='log', log_a=10000))" 57 | ] 58 | }, 59 | { 60 | "cell_type": "markdown", 61 | "metadata": {}, 62 | "source": [ 63 | "**Applying realism**\n", 64 | "\n", 65 | "We now apply some \"realism\" (PSF + noise) to the idealized image (see the [tutorial](./tutorial.html) for more details):" 66 | ] 67 | }, 68 | { 69 | "cell_type": "code", 70 | "execution_count": null, 71 | "metadata": {}, 72 | "outputs": [], 73 | "source": [ 74 | "# Convolve with PSF\n", 75 | "kernel = Gaussian2DKernel(2.0)\n", 76 | "kernel.normalize() # make sure kernel adds up to 1\n", 77 | "psf = kernel.array # we only need the numpy array\n", 78 | "image = convolve(image, psf)\n", 79 | "\n", 80 | "# Apply shot noise\n", 81 | "np.random.seed(3)\n", 82 | "gain = 1e5\n", 83 | "image = np.random.poisson(image * gain) / gain\n", 84 | "\n", 85 | "# Apply background noise\n", 86 | "sky_sigma = 0.01\n", 87 | "image += sky_sigma * np.random.standard_normal(size=(ny, nx))\n", 88 | "\n", 89 | "# Visualize \"realistic\" image\n", 90 | "plt.imshow(image, cmap='gray', origin='lower',\n", 91 | " norm=simple_norm(image, stretch='log', log_a=10000))" 92 | ] 93 | }, 94 | { 95 | "cell_type": "markdown", 96 | "metadata": {}, 97 | "source": [ 98 | "**Creating a segmentation map**\n", 99 | "\n", 100 | "We also need to create a segmentation image (see the [tutorial](./tutorial.html) for more details):" 101 | ] 102 | }, 103 | { 104 | "cell_type": "code", 105 | "execution_count": null, 106 | "metadata": {}, 107 | "outputs": [], 108 | "source": [ 109 | "threshold = detect_threshold(image, 1.5)\n", 110 | "npixels = 5 # minimum number of connected pixels\n", 111 | "convolved_image = convolve(image, psf)\n", 112 | "segmap = detect_sources(convolved_image, threshold, npixels)\n", 113 | "plt.imshow(segmap, origin='lower', cmap='gray')" 114 | ] 115 | }, 116 | { 117 | "cell_type": "markdown", 118 | "metadata": {}, 119 | "source": [ 120 | "### Running statmorph\n", 121 | "\n", 122 | "We now have all the input necessary to run statmorph. However, unlike in the [tutorial](./tutorial.html), this time we include the option ``include_doublesersic = True``, which is necessary in order to carry out the double Sersic fit." 123 | ] 124 | }, 125 | { 126 | "cell_type": "code", 127 | "execution_count": null, 128 | "metadata": {}, 129 | "outputs": [], 130 | "source": [ 131 | "start = time.time()\n", 132 | "source_morphs = statmorph.source_morphology(\n", 133 | " image, segmap, gain=gain, psf=psf, include_doublesersic=True)\n", 134 | "print('Time: %g s.' % (time.time() - start))" 135 | ] 136 | }, 137 | { 138 | "cell_type": "markdown", 139 | "metadata": {}, 140 | "source": [ 141 | "### Examining the output\n", 142 | "\n", 143 | "We focus on the first (and only) source labeled in the segmap:" 144 | ] 145 | }, 146 | { 147 | "cell_type": "code", 148 | "execution_count": null, 149 | "metadata": {}, 150 | "outputs": [], 151 | "source": [ 152 | "morph = source_morphs[0]" 153 | ] 154 | }, 155 | { 156 | "cell_type": "markdown", 157 | "metadata": {}, 158 | "source": [ 159 | "We print some of the morphological properties just calculated:" 160 | ] 161 | }, 162 | { 163 | "cell_type": "code", 164 | "execution_count": null, 165 | "metadata": {}, 166 | "outputs": [], 167 | "source": [ 168 | "print('BASIC MEASUREMENTS (NON-PARAMETRIC)')\n", 169 | "print('xc_centroid =', morph.xc_centroid)\n", 170 | "print('yc_centroid =', morph.yc_centroid)\n", 171 | "print('ellipticity_centroid =', morph.ellipticity_centroid)\n", 172 | "print('elongation_centroid =', morph.elongation_centroid)\n", 173 | "print('orientation_centroid =', morph.orientation_centroid)\n", 174 | "print('xc_asymmetry =', morph.xc_asymmetry)\n", 175 | "print('yc_asymmetry =', morph.yc_asymmetry)\n", 176 | "print('ellipticity_asymmetry =', morph.ellipticity_asymmetry)\n", 177 | "print('elongation_asymmetry =', morph.elongation_asymmetry)\n", 178 | "print('orientation_asymmetry =', morph.orientation_asymmetry)\n", 179 | "print('rpetro_circ =', morph.rpetro_circ)\n", 180 | "print('rpetro_ellip =', morph.rpetro_ellip)\n", 181 | "print('rhalf_circ =', morph.rhalf_circ)\n", 182 | "print('rhalf_ellip =', morph.rhalf_ellip)\n", 183 | "print('r20 =', morph.r20)\n", 184 | "print('r80 =', morph.r80)\n", 185 | "print('Gini =', morph.gini)\n", 186 | "print('M20 =', morph.m20)\n", 187 | "print('F(G, M20) =', morph.gini_m20_bulge)\n", 188 | "print('S(G, M20) =', morph.gini_m20_merger)\n", 189 | "print('sn_per_pixel =', morph.sn_per_pixel)\n", 190 | "print('C =', morph.concentration)\n", 191 | "print('A =', morph.asymmetry)\n", 192 | "print('S =', morph.smoothness)\n", 193 | "print()\n", 194 | "print('SINGLE SERSIC')\n", 195 | "print('sersic_amplitude =', morph.sersic_amplitude)\n", 196 | "print('sersic_rhalf =', morph.sersic_rhalf)\n", 197 | "print('sersic_n =', morph.sersic_n)\n", 198 | "print('sersic_xc =', morph.sersic_xc)\n", 199 | "print('sersic_yc =', morph.sersic_yc)\n", 200 | "print('sersic_ellip =', morph.sersic_ellip)\n", 201 | "print('sersic_theta =', morph.sersic_theta)\n", 202 | "print('sersic_chi2_dof =', morph.sersic_chi2_dof)\n", 203 | "print('sersic_aic =', morph.sersic_aic)\n", 204 | "print('sersic_bic =', morph.sersic_bic)\n", 205 | "print()\n", 206 | "print('DOUBLE SERSIC')\n", 207 | "print('doublesersic_xc =', morph.doublesersic_xc)\n", 208 | "print('doublesersic_yc =', morph.doublesersic_yc)\n", 209 | "print('doublesersic_amplitude1 =', morph.doublesersic_amplitude1)\n", 210 | "print('doublesersic_rhalf1 =', morph.doublesersic_rhalf1)\n", 211 | "print('doublesersic_n1 =', morph.doublesersic_n1)\n", 212 | "print('doublesersic_ellip1 =', morph.doublesersic_ellip1)\n", 213 | "print('doublesersic_theta1 =', morph.doublesersic_theta1)\n", 214 | "print('doublesersic_amplitude2 =', morph.doublesersic_amplitude2)\n", 215 | "print('doublesersic_rhalf2 =', morph.doublesersic_rhalf2)\n", 216 | "print('doublesersic_n2 =', morph.doublesersic_n2)\n", 217 | "print('doublesersic_ellip2 =', morph.doublesersic_ellip2)\n", 218 | "print('doublesersic_theta2 =', morph.doublesersic_theta2)\n", 219 | "print('doublesersic_chi2_dof =', morph.doublesersic_chi2_dof)\n", 220 | "print('doublesersic_aic =', morph.doublesersic_aic)\n", 221 | "print('doublesersic_bic =', morph.doublesersic_bic)\n", 222 | "print()\n", 223 | "print('OTHER')\n", 224 | "print('sky_mean =', morph.sky_mean)\n", 225 | "print('sky_median =', morph.sky_median)\n", 226 | "print('sky_sigma =', morph.sky_sigma)\n", 227 | "print('flag =', morph.flag)\n", 228 | "print('flag_sersic =', morph.flag_sersic)\n", 229 | "print('flag_doublesersic =', morph.flag_doublesersic)" 230 | ] 231 | }, 232 | { 233 | "cell_type": "markdown", 234 | "metadata": {}, 235 | "source": [ 236 | "Clearly, the fitted double Sersic model is consistent with the \"true\" light distribution that we originally created (n1 = 5, n2 = 1, etc.) and the reduced chi-squared statistic (doublesersic_chi2_dof) is close to 1, indicating a good fit without overfitting. On the other hand, the *single* Sersic fit has a reduced chi-squared statistic much larger than 1, indicating a poor fit (as expected).\n", 237 | "\n", 238 | "We also calculate the Akaike Information Criterion (AIC) and Bayesian Information Criterion (BIC) for the two models, which again favor the *double* Sersic model as the statistically preferred one, since it returns much lower AIC and BIC values.\n", 239 | "\n", 240 | "Also note that statmorph now returns an additional quality flag:\n", 241 | "\n", 242 | "- ``flag_doublesersic`` : indicates the quality of the double Sersic fit. Like ``flag`` and ``flag_sersic``, it can take the following values: 0 (good), 1 (suspect), 2 (bad), and 4 (catastrophic)." 243 | ] 244 | }, 245 | { 246 | "cell_type": "markdown", 247 | "metadata": {}, 248 | "source": [ 249 | "**Visualizing the individual components**\n", 250 | "\n", 251 | "For some applications (e.g. bulge/disk decompositions) it might be useful to analyze the two fitted components separately, as we do below." 252 | ] 253 | }, 254 | { 255 | "cell_type": "code", 256 | "execution_count": null, 257 | "metadata": {}, 258 | "outputs": [], 259 | "source": [ 260 | "ny, nx = image.shape\n", 261 | "y, x = np.mgrid[0:ny, 0:nx]\n", 262 | "sersic1 = Sersic2D(morph.doublesersic_amplitude1,\n", 263 | " morph.doublesersic_rhalf1,\n", 264 | " morph.doublesersic_n1,\n", 265 | " morph.doublesersic_xc,\n", 266 | " morph.doublesersic_yc,\n", 267 | " morph.doublesersic_ellip1,\n", 268 | " morph.doublesersic_theta1)\n", 269 | "sersic2 = Sersic2D(morph.doublesersic_amplitude2,\n", 270 | " morph.doublesersic_rhalf2,\n", 271 | " morph.doublesersic_n2,\n", 272 | " morph.doublesersic_xc,\n", 273 | " morph.doublesersic_yc,\n", 274 | " morph.doublesersic_ellip2,\n", 275 | " morph.doublesersic_theta2)\n", 276 | "image1 = sersic1(x, y)\n", 277 | "image2 = sersic2(x, y)\n", 278 | "image_total = image1 + image2\n", 279 | "\n", 280 | "fig = plt.figure(figsize=(15,5))\n", 281 | "ax = fig.add_subplot(131)\n", 282 | "ax.imshow(image1, cmap='gray', origin='lower',\n", 283 | " norm=simple_norm(image_total, stretch='log', log_a=10000))\n", 284 | "ax.set_title('First component')\n", 285 | "ax.text(0.04, 0.93, 'n1 = %.4f' % (morph.doublesersic_n1,),\n", 286 | " bbox=dict(facecolor='white'), transform=ax.transAxes)\n", 287 | "ax = fig.add_subplot(132)\n", 288 | "ax.imshow(image2, cmap='gray', origin='lower',\n", 289 | " norm=simple_norm(image_total, stretch='log', log_a=10000))\n", 290 | "ax.set_title('Second component')\n", 291 | "ax.text(0.04, 0.93, 'n2 = %.4f' % (morph.doublesersic_n2,),\n", 292 | " bbox=dict(facecolor='white'), transform=ax.transAxes)\n", 293 | "ax = fig.add_subplot(133)\n", 294 | "ax.imshow(image_total, cmap='gray', origin='lower',\n", 295 | " norm=simple_norm(image_total, stretch='log', log_a=10000))\n", 296 | "ax.set_title('Composite model')" 297 | ] 298 | }, 299 | { 300 | "cell_type": "markdown", 301 | "metadata": {}, 302 | "source": [ 303 | "Note that the two Sersic components shown above are *not* convolved with the PSF, since they are meant to recover the \"true\" light distributions of the two components of the galaxy." 304 | ] 305 | }, 306 | { 307 | "cell_type": "markdown", 308 | "metadata": {}, 309 | "source": [ 310 | "**Examining the single Sersic fit**\n", 311 | "\n", 312 | "For illustration puposes, below we compare the original (realistic) image to the *single* Sersic fit." 313 | ] 314 | }, 315 | { 316 | "cell_type": "code", 317 | "execution_count": null, 318 | "metadata": {}, 319 | "outputs": [], 320 | "source": [ 321 | "bg_noise = sky_sigma * np.random.standard_normal(size=(ny, nx))\n", 322 | "model = statmorph.ConvolvedSersic2D(\n", 323 | " morph.sersic_amplitude,\n", 324 | " morph.sersic_rhalf,\n", 325 | " morph.sersic_n,\n", 326 | " morph.sersic_xc,\n", 327 | " morph.sersic_yc,\n", 328 | " morph.sersic_ellip,\n", 329 | " morph.sersic_theta)\n", 330 | "model.set_psf(psf) # must set PSF by hand\n", 331 | "image_model = model(x, y)\n", 332 | "\n", 333 | "fig = plt.figure(figsize=(15,5))\n", 334 | "ax = fig.add_subplot(131)\n", 335 | "ax.imshow(image, cmap='gray', origin='lower',\n", 336 | " norm=simple_norm(image, stretch='log', log_a=10000))\n", 337 | "ax.set_title('Original image')\n", 338 | "ax = fig.add_subplot(132)\n", 339 | "ax.imshow(image_model + bg_noise, cmap='gray', origin='lower',\n", 340 | " norm=simple_norm(image, stretch='log', log_a=10000))\n", 341 | "ax.set_title('Single Sersic fit')\n", 342 | "ax = fig.add_subplot(133)\n", 343 | "residual = image - image_model\n", 344 | "ax.imshow(residual, cmap='gray', origin='lower',\n", 345 | " norm=simple_norm(residual, stretch='linear'))\n", 346 | "ax.set_title('Single Sersic residual')" 347 | ] 348 | }, 349 | { 350 | "cell_type": "markdown", 351 | "metadata": {}, 352 | "source": [ 353 | "**Examining the double Sersic fit**\n", 354 | "\n", 355 | "Similarly, below we compare the original (realistic) image to the *double* Sersic fit." 356 | ] 357 | }, 358 | { 359 | "cell_type": "code", 360 | "execution_count": null, 361 | "metadata": {}, 362 | "outputs": [], 363 | "source": [ 364 | "model = statmorph.ConvolvedDoubleSersic2D(\n", 365 | " morph.doublesersic_xc,\n", 366 | " morph.doublesersic_yc,\n", 367 | " morph.doublesersic_amplitude1,\n", 368 | " morph.doublesersic_rhalf1,\n", 369 | " morph.doublesersic_n1,\n", 370 | " morph.doublesersic_ellip1,\n", 371 | " morph.doublesersic_theta1,\n", 372 | " morph.doublesersic_amplitude2,\n", 373 | " morph.doublesersic_rhalf2,\n", 374 | " morph.doublesersic_n2,\n", 375 | " morph.doublesersic_ellip2,\n", 376 | " morph.doublesersic_theta2)\n", 377 | "model.set_psf(psf) # must set PSF by hand\n", 378 | "image_model = model(x, y)\n", 379 | "\n", 380 | "fig = plt.figure(figsize=(15,5))\n", 381 | "ax = fig.add_subplot(131)\n", 382 | "ax.imshow(image, cmap='gray', origin='lower',\n", 383 | " norm=simple_norm(image, stretch='log', log_a=10000))\n", 384 | "ax.set_title('Original image')\n", 385 | "ax = fig.add_subplot(132)\n", 386 | "ax.imshow(image_model + bg_noise, cmap='gray', origin='lower',\n", 387 | " norm=simple_norm(image, stretch='log', log_a=10000))\n", 388 | "ax.set_title('Double Sersic fit')\n", 389 | "ax = fig.add_subplot(133)\n", 390 | "residual = image - image_model\n", 391 | "ax.imshow(residual, cmap='gray', origin='lower',\n", 392 | " norm=simple_norm(residual, stretch='linear'))\n", 393 | "ax.set_title('Double Sersic residual')" 394 | ] 395 | }, 396 | { 397 | "cell_type": "code", 398 | "execution_count": null, 399 | "metadata": {}, 400 | "outputs": [], 401 | "source": [ 402 | "fig.savefig('doublesersic.png', dpi=150)\n", 403 | "plt.close(fig)" 404 | ] 405 | }, 406 | { 407 | "cell_type": "markdown", 408 | "metadata": {}, 409 | "source": [ 410 | "### Concluding remarks\n", 411 | "\n", 412 | "The fact that statmorph uses Astropy's modeling utility behind the scenes provides a great deal of flexibility. For example, if one is interested in fitting a de Vaucouleurs + exponential model (these components are, of course, special cases of the Sersic model with `n = 4` and `n = 1`, respectively), one simply has to add the following option when calling statmorph:\n", 413 | "\n", 414 | " doublesersic_model_args = {\n", 415 | " 'n_1': 4, 'n_2': 1, 'fixed': {'n_1': True, 'n_2': True}}\n", 416 | "\n", 417 | "Furthermore, in some applications it might make sense to \"tie\" the ellipticity and position angle of the two Sersic components. This can also be accomplished using ``doublesersic_model_args`` in combination with the ``tied`` property of Astropy parameters, although the syntax is slightly more involved (more details [here](https://docs.astropy.org/en/stable/modeling/parameters.html)). Alternatively, statmorph provides the following option for this purpose, which achieves the same effect:\n", 418 | "\n", 419 | " doublesersic_tied_ellip = True" 420 | ] 421 | } 422 | ], 423 | "metadata": { 424 | "kernelspec": { 425 | "display_name": "Python 3 (ipykernel)", 426 | "language": "python", 427 | "name": "python3" 428 | }, 429 | "language_info": { 430 | "codemirror_mode": { 431 | "name": "ipython", 432 | "version": 3 433 | }, 434 | "file_extension": ".py", 435 | "mimetype": "text/x-python", 436 | "name": "python", 437 | "nbconvert_exporter": "python", 438 | "pygments_lexer": "ipython3", 439 | "version": "3.11.6" 440 | } 441 | }, 442 | "nbformat": 4, 443 | "nbformat_minor": 4 444 | } 445 | --------------------------------------------------------------------------------