├── src └── astrometry_azel │ ├── tests │ ├── __init__.py │ ├── credits.txt │ ├── apod4.jpg │ ├── apod4.new │ ├── apod4.fits │ ├── test_all.py │ └── apod4.wcs │ ├── project.py │ ├── plot │ ├── project.py │ └── __init__.py │ ├── io.py │ └── __init__.py ├── .flake8 ├── .gitignore ├── Readme_build.md ├── scripts ├── PrintSourceRaDec.py ├── AverageImageStack.py ├── OverlayAltitudes.py ├── PlateScaleFITS.py ├── LocateCrop.py └── OverlayStars.py ├── LICENSE.txt ├── .github └── workflows │ └── ci.yml ├── pyproject.toml ├── PlateScale.py ├── PlotGeomap.py ├── downloadIndex.py ├── archive └── modifyFITSheaderAstrometry.m └── README.md /src/astrometry_azel/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # empty file to be able to use this directory as a package 2 | -------------------------------------------------------------------------------- /src/astrometry_azel/tests/credits.txt: -------------------------------------------------------------------------------- 1 | https://github.com/dstndstn/astrometry.net/blob/master/demo/apod4.jpg 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 132 3 | ignore = E501 4 | exclude = .git,__pycache__,.eggs/,doc/,docs/,build/,dist/,archive/ 5 | -------------------------------------------------------------------------------- /src/astrometry_azel/tests/apod4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/space-physics/astrometry_geomap/HEAD/src/astrometry_azel/tests/apod4.jpg -------------------------------------------------------------------------------- /src/astrometry_azel/tests/apod4.new: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/space-physics/astrometry_geomap/HEAD/src/astrometry_azel/tests/apod4.new -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .mypy_cache/ 3 | .pytest_cache/ 4 | 5 | 6 | build/ 7 | dist/ 8 | *.pyc 9 | __pycache__ 10 | 11 | *.egg-info 12 | -------------------------------------------------------------------------------- /src/astrometry_azel/tests/apod4.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/space-physics/astrometry_geomap/HEAD/src/astrometry_azel/tests/apod4.fits -------------------------------------------------------------------------------- /Readme_build.md: -------------------------------------------------------------------------------- 1 | # build astrometry.net 2 | 3 | Usually we don't need to build Astrometry.net. 4 | 5 | Linux or Windows Subsystem for Linux: 6 | 7 | curl -O http://astrometry.net/downloads/astrometry.net-latest.tar.gz 8 | 9 | tar xf astrometry.net-latest.tar.gz 10 | 11 | cd astrometry* 12 | 13 | apt install gcc make libcairo2-dev libnetpbm10-dev netpbm libpng-dev libjpeg-dev python3-numpy python3-pyfits python3-dev zlib1g-dev libbz2-dev swig libcfitsio-dev 14 | 15 | make -j 16 | 17 | make py -j 18 | 19 | make extra -j 20 | 21 | make install -j INSTALL_DIR=$HOME/.local/astrometry 22 | 23 | open a new terminal to use. 24 | -------------------------------------------------------------------------------- /scripts/PrintSourceRaDec.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Utility program to print source Coordinates in RA,DEC that astrometry.net found 4 | use this with .rdls (rdls.fits) FITS files after solving an image 5 | """ 6 | 7 | import argparse 8 | 9 | from astrometry_azel.io import get_sources 10 | 11 | 12 | p = argparse.ArgumentParser(description="show RA DEC in .rdls files after solving an image") 13 | p.add_argument("fn", help=".rdls file from astrometry.net") 14 | p = p.parse_args() 15 | 16 | radec_sources = get_sources(p.fn) 17 | 18 | print(radec_sources.shape[0], "sources found in", p.fn, "with ra,dec coordinates:") 19 | 20 | print(radec_sources) 21 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2020 SciVision, Inc. 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | paths: 6 | - "**.py" 7 | - ".github/workflows/ci.yml" 8 | 9 | env: 10 | HOMEBREW_NO_INSTALL_CLEANUP: 1 11 | 12 | 13 | jobs: 14 | 15 | core: 16 | 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest] 20 | python_version: ['3.9', '3.12'] 21 | 22 | runs-on: ${{ matrix.os }} 23 | 24 | steps: 25 | 26 | - name: "astrometry.net (Linux)" 27 | if: runner.os == 'Linux' 28 | run: sudo apt install --no-install-recommends astrometry.net astrometry-data-tycho2-10-19 29 | 30 | - name: "astrometry.net (macOS)" 31 | # FIXME: would also need data installed 32 | if: runner.os == 'macOS' 33 | run: brew install astrometry-net 34 | 35 | - uses: actions/checkout@v4 36 | 37 | - uses: actions/setup-python@v5 38 | with: 39 | python-version: ${{ matrix.python_version }} 40 | 41 | - run: pip install .[tests,lint] 42 | 43 | - run: flake8 44 | - run: mypy 45 | 46 | - run: pytest 47 | -------------------------------------------------------------------------------- /scripts/AverageImageStack.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | aveage multi frame image stacks to improve SNR 4 | """ 5 | 6 | import imageio 7 | from pathlib import Path 8 | import typing 9 | import numpy as np 10 | import argparse 11 | 12 | import astrometry_azel.io as aio 13 | 14 | 15 | def stackcollapse(imgfn: Path, inds: typing.Sequence[int]): 16 | imgs = np.asarray(imageio.mimread(imgfn)) 17 | 18 | for i in range(len(inds) - 1): 19 | yield aio.collapsestack(imgs, slice(inds[i], inds[i + 1]), method="mean") 20 | 21 | 22 | if __name__ == "__main__": 23 | p = argparse.ArgumentParser() 24 | p.add_argument("imgfn", help="multi-image file e.g. animated giff, TIFF, etc") 25 | p.add_argument("slice", help="start, stop, step", nargs=3, type=int) 26 | p.add_argument("-o", "--outpath") 27 | P = p.parse_args() 28 | 29 | imgfn = Path(P.imgfn).expanduser() 30 | outpath = Path(P.outpath).expanduser() if P.outpath else imgfn.parent 31 | 32 | for i, img in enumerate(stackcollapse(P.imgfn, list(range(*P.slice)))): 33 | outfn = outpath / f"{imgfn.stem}_{i}.png" 34 | print("writing", outfn) 35 | imageio.imwrite(outfn, img) 36 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "astrometry_azel" 7 | description = "Register images to geographic maps using the astrometry.net program" 8 | keywords = ["astrometry", "plate-scale", "astronomy", "aurora"] 9 | classifiers = [ 10 | "5 - Production/Stable", 11 | "Environment :: Console", 12 | "Operating System :: OS Independent", 13 | "Programming Language :: Python :: 3", 14 | "Intended Audience :: Science/Research", 15 | "Topic :: Scientific/Engineering :: Atmospheric Science" 16 | ] 17 | dynamic = ["readme", "version"] 18 | requires-python = ">=3.9" 19 | dependencies = ["python-dateutil", "numpy", "astropy", "xarray", "netcdf4"] 20 | 21 | [project.optional-dependencies] 22 | tests = ["pytest"] 23 | lint = ["flake8", "flake8-bugbear", "flake8-builtins", "flake8-blind-except", "mypy", "types-python-dateutil"] 24 | 25 | [tool.setuptools.dynamic] 26 | readme = {file = ["README.md"], content-type = "text/markdown"} 27 | version = {attr = "astrometry_azel.__version__"} 28 | 29 | [tool.black] 30 | line-length = 100 31 | 32 | [tool.mypy] 33 | files = ["src"] 34 | allow_redefinition = true 35 | ignore_missing_imports = true 36 | -------------------------------------------------------------------------------- /scripts/OverlayAltitudes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Plot overlay of images that have been registered (RA/DEC is adequte). 4 | For simplicity, the FITS images with Astrometry.net inserted WCS coordinates are used. 5 | 6 | The program could be slightly upgraded to optionally use the original image and the .wcs file from Astrometry.net. 7 | 8 | Note: one can use WCS projection: 9 | http://docs.astropy.org/en/stable/visualization/wcsaxes/ 10 | 11 | Example 12 | 13 | python OverlayAltitudes.py blue.new red.new green.new 14 | """ 15 | 16 | import typing 17 | from pathlib import Path 18 | from argparse import ArgumentParser 19 | 20 | from astrometry_azel.plot import wcs_image 21 | 22 | from matplotlib.pyplot import figure, show 23 | 24 | 25 | p = ArgumentParser() 26 | p.add_argument("flist", help='FITS ".new" WCS registered filenames to plot together', nargs="+") 27 | p.add_argument("-s", "--subplots", help="subplots instead of overlay", action="store_true") 28 | p.add_argument("--suptitle", help="overall text for suptitle") 29 | p = p.parse_args() 30 | 31 | flist = [Path(f).expanduser() for f in p.flist] 32 | 33 | cmaps = ("Blues", "Reds", "Greens") 34 | fg = figure() 35 | fg.suptitle(p.suptitle) 36 | 37 | if p.subplots: 38 | axs: typing.Any = fg.subplots(1, len(flist), sharey=True, sharex=True) 39 | for fn, ax in zip(flist, axs): 40 | wcs_image(fn, "gray", ax) 41 | 42 | else: 43 | ax = fg.gca() 44 | for fn, cm in zip(flist, cmaps): 45 | wcs_image(fn, cm, ax, alpha=0.05) 46 | 47 | show() 48 | -------------------------------------------------------------------------------- /PlateScale.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | script to plate scale data in FITS or HDF5 format. 4 | 5 | If the image starfile was solved using astrometry.net web service, 6 | the ".new" FITS file is the input to this program. 7 | """ 8 | 9 | from pathlib import Path 10 | from argparse import ArgumentParser 11 | 12 | from astrometry_azel.project import plate_scale 13 | import astrometry_azel.plot as plot 14 | 15 | 16 | p = ArgumentParser(description="do plate scaling for image data") 17 | p.add_argument("infn", help="image data file name (HDF5 or FITS)") 18 | p.add_argument("latlon", help="wgs84 coordinates of cameras (deg.)", nargs=2, type=float) 19 | p.add_argument("ut1", help="UT1 time yyyy-mm-ddTHH:MM:SSZ") 20 | p.add_argument("-s", "--solve", help="run solve-field step of astrometry.net", action="store_true") 21 | p.add_argument("-a", "--args", help="arguments to pass through to solve-field", default="") 22 | P = p.parse_args() 23 | 24 | path = Path(P.infn).expanduser() 25 | 26 | print(P.latlon) 27 | 28 | try: 29 | scale, img = plate_scale(path, P.latlon, P.ut1, P.solve, P.args) 30 | except FileNotFoundError as e: 31 | if "could not find WCS file" in str(e): 32 | raise RuntimeError( 33 | f"Please specify PlateScale.py --solve option to run solve-field on {path}" 34 | ) 35 | 36 | outfn = Path(scale.filename) 37 | outdir = outfn.parent 38 | outstem = outfn.stem 39 | 40 | fnr = outdir / (outstem + "_radec.png") 41 | fna = outdir / (outstem + "_azel.png") 42 | 43 | print("writing", fnr, fna) 44 | 45 | fg = plot.ra_dec(scale, img=img) 46 | fg.savefig(fnr) 47 | 48 | fg = plot.az_el(scale, img=img) 49 | fg.savefig(fna) 50 | -------------------------------------------------------------------------------- /PlotGeomap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Use output netCDF from PlateScale.py to plot image as if the photons emitted at a single altitude. 4 | This technique is used in aeronomy for aurora and airglow. 5 | """ 6 | 7 | from __future__ import annotations 8 | import argparse 9 | from pathlib import Path 10 | 11 | from matplotlib.pyplot import show 12 | 13 | from astrometry_azel.io import read_data, write_netcdf 14 | import astrometry_azel.project as project 15 | import astrometry_azel.plot.project as plot_project 16 | 17 | 18 | p = argparse.ArgumentParser( 19 | description="plot geomap of image as if photons emitted at a single altitude" 20 | ) 21 | p.add_argument("in_file", help="netCDF file from PlateScale.py") 22 | p.add_argument("projection_altitude_km", type=float, help="altitude of emission (kilometers)") 23 | p.add_argument( 24 | "-minel", "--minimum_elevation", type=float, default=0.0, help="minimum elevation (degrees)" 25 | ) 26 | p.add_argument( 27 | "-obsalt", 28 | "--observer_altitude_m", 29 | type=float, 30 | help="altitude of observer (meters)", 31 | default=0.0, 32 | ) 33 | P = p.parse_args() 34 | 35 | in_file = Path(P.in_file).expanduser() 36 | 37 | img = read_data(in_file) 38 | 39 | img = project.image_altitude(img, P.projection_altitude_km, P.observer_altitude_m) 40 | 41 | out_file = in_file.parent / (in_file.stem + "_proj.nc") 42 | print("Save projected data to", out_file) 43 | write_netcdf(img, out_file) 44 | 45 | fig = plot_project.geomap(img, P.minimum_elevation) 46 | 47 | figure_fn = in_file.parent / (in_file.stem + "_proj.png") 48 | print("Save projected image to", figure_fn) 49 | fig.savefig(figure_fn) 50 | 51 | show() 52 | -------------------------------------------------------------------------------- /scripts/PlateScaleFITS.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | original frontend to fits2azel (requires fits input) 4 | Consider using the more general PlateScale.py 5 | """ 6 | 7 | from argparse import ArgumentParser 8 | from numpy import datetime64 9 | import astrometry_azel as ael 10 | import astrometry_azel.plot as plot 11 | from matplotlib.pyplot import show 12 | 13 | 14 | def main(): 15 | p = ArgumentParser(description="converts fits starfield image to RA/Dec and az/el") 16 | p.add_argument("infile", help=".fits or .wcs filename with path", type=str) 17 | p.add_argument( 18 | "-c", "--latlon", help="tuple of WGS-84 (lat,lon) [degrees]", nargs=2, type=float 19 | ) 20 | p.add_argument("-t", "--time", help="override UTC time yyyy-mm-ddThh:mm:ss") 21 | p.add_argument( 22 | "-s", "--solve", help="run solve-field step of astrometry.net", action="store_true" 23 | ) 24 | p.add_argument( 25 | "--clim", 26 | help="clim of preview images (no effect on computation)", 27 | nargs=2, 28 | default=None, 29 | type=float, 30 | ) 31 | p.add_argument("-a", "--args", help="arguments to pass through to solve-field") 32 | P = p.parse_args() 33 | 34 | # %% actually run program 35 | scale = ael.fits2azel(P.infile, latlon=P.latlon, time=P.time, solve=P.solve, args=P.args) 36 | # %% write to file 37 | outfn = scale.filename.with_suffix(".nc") 38 | print("saving", outfn) 39 | scale["filename"] = str(scale.filename) 40 | scale["time"] = datetime64(scale.time) 41 | scale.to_netcdf(outfn, format="NETCDF4", engine="netcdf4") 42 | 43 | # %% plot 44 | if show is not None: 45 | plot.ra_dec(scale) 46 | plot.az_el(scale) 47 | 48 | show() 49 | 50 | 51 | if __name__ == "__main__": 52 | main() 53 | -------------------------------------------------------------------------------- /src/astrometry_azel/tests/test_all.py: -------------------------------------------------------------------------------- 1 | """ 2 | solves test image from 3 | http://nova.astrometry.net/user_images/1572720 4 | """ 5 | 6 | import pytest 7 | from pathlib import Path 8 | from pytest import approx 9 | import shutil 10 | 11 | import astrometry_azel as ael 12 | 13 | rdir = Path(__file__).parent 14 | fits = rdir / "apod4.fits" 15 | 16 | exe = shutil.which("solve-field") 17 | 18 | 19 | def test_nosolve(tmp_path): 20 | with pytest.raises(FileNotFoundError): 21 | ael.doSolve(tmp_path) 22 | 23 | 24 | @pytest.fixture(scope="function") 25 | def fits_file(tmp_path) -> Path: 26 | shutil.copy(fits.with_suffix(".new"), tmp_path) 27 | shutil.copy(fits.with_suffix(".wcs"), tmp_path) 28 | 29 | return tmp_path / (fits.stem + ".new") 30 | 31 | 32 | @pytest.mark.skipif(exe is None, reason="solve-field missing") 33 | def test_solve(tmp_path): 34 | shutil.copy(fits, tmp_path) 35 | 36 | ael.doSolve(tmp_path / fits.name) 37 | 38 | 39 | def test_fits2radec(fits_file): 40 | scale = ael.fits2radec(fits_file) 41 | 42 | ra_expected = [152.35248165, 157.96163129, 164.95871358] 43 | assert scale["ra"].values[[32, 51, 98], [28, 92, 156]] == approx(ra_expected, rel=0.01) 44 | 45 | dec_expected = [59.98073175, 59.20526844, 59.18426375] 46 | assert scale["dec"].values[[32, 51, 98], [28, 92, 156]] == approx(dec_expected, rel=0.01) 47 | 48 | 49 | def test_fits2azel(fits_file): 50 | pytest.importorskip("pymap3d") 51 | 52 | scale = ael.fits2azel(fits_file, latlon=(0, 0), time="2000-01-01T00:00") 53 | 54 | az_expected = [24.59668217, 26.81546529, 28.39753029] 55 | assert scale["azimuth"].values[[32, 51, 98], [28, 92, 156]] == approx(az_expected, rel=0.01) 56 | 57 | el_expected = [17.78086795, 15.74570897, 12.50919858] 58 | assert scale["elevation"].values[[32, 51, 98], [28, 92, 156]] == approx(el_expected, rel=0.01) 59 | -------------------------------------------------------------------------------- /src/astrometry_azel/project.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from pathlib import Path 3 | from datetime import datetime 4 | 5 | import xarray 6 | import numpy as np 7 | 8 | from .io import load_image, write_fits, write_netcdf 9 | from . import fits2azel 10 | 11 | import pymap3d 12 | 13 | 14 | def plate_scale( 15 | in_file: Path, 16 | latlon: tuple[float, float], 17 | ut1: datetime, 18 | solve: bool, 19 | args: str, 20 | ) -> tuple: 21 | # %% filenames 22 | in_file = Path(in_file).expanduser().resolve() 23 | 24 | # %% convert input image to FITS 25 | new_file = in_file.parent / (in_file.stem + "_new.fits") 26 | img = load_image(in_file) 27 | write_fits(img, new_file) 28 | 29 | scale = fits2azel(new_file, latlon=latlon, time=ut1, solve=solve, args=args) 30 | 31 | # %% write to file 32 | netcdf_file = Path(scale.filename).with_suffix(".nc") 33 | print("saving", netcdf_file) 34 | write_netcdf(scale, netcdf_file) 35 | 36 | return scale, img 37 | 38 | 39 | def image_altitude(img: xarray.Dataset, projection_altitude_km: float, observer_altitude_m: float): 40 | """ 41 | project image to projection_altitude_km 42 | 43 | adapted from https://github.com/space-physics/dascasi 44 | """ 45 | 46 | slant_range_m = projection_altitude_km * 1e3 / np.sin(np.radians(img["elevation"])) 47 | # secant approximation 48 | 49 | lat, lon, _ = pymap3d.aer2geodetic( 50 | az=img["azimuth"], 51 | el=img["elevation"], 52 | srange=slant_range_m, # meters 53 | lat0=img["observer_latitude"].item(), # degrees north 54 | lon0=img["observer_longitude"].item(), # degrees east 55 | h0=observer_altitude_m, # meters 56 | ) 57 | 58 | img.coords["latitude_proj"] = (("y", "x"), lat) 59 | img["latitude_proj"].attrs["projection_altitude_km"] = projection_altitude_km 60 | img["latitude_proj"].attrs["units"] = "degrees north WGS84" 61 | 62 | img.coords["longitude_proj"] = (("y", "x"), lon) 63 | img["longitude_proj"].attrs["projection_altitude_km"] = projection_altitude_km 64 | img["longitude_proj"].attrs["units"] = "degrees east WGS84" 65 | 66 | return img 67 | -------------------------------------------------------------------------------- /scripts/LocateCrop.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Identify coordinates of cropped image in original image. 4 | Handy for when you cropped visually or forgot the cropping parameters. 5 | 6 | Template matching is used. 7 | """ 8 | 9 | from __future__ import annotations 10 | import typing 11 | import skimage.feature as skf 12 | import imageio 13 | import numpy as np 14 | from pathlib import Path 15 | import argparse 16 | from matplotlib.pyplot import figure, show 17 | 18 | 19 | def load_gray(fn1: Path, fn2: Path) -> tuple: 20 | """ 21 | load images in grayscale and same bit depth 22 | """ 23 | 24 | rgb2gray = [0.299, 0.587, 0.114] 25 | 26 | im1 = imageio.imread(fn1).dot(rgb2gray).astype(np.uint8) 27 | im2 = imageio.imread(fn2).dot(rgb2gray).astype(np.uint8) 28 | 29 | return im1, im2 30 | 31 | 32 | def find_crop(im1, im2) -> tuple[int, ...]: 33 | res = skf.match_template(im1, im2) 34 | # values (-1, 1) and peak is at upper left corner of match. 35 | Ul = np.unravel_index(res.argmax(), res.shape) 36 | 37 | return Ul 38 | 39 | 40 | def plot_overlay(im1, im2, Ul: typing.Sequence[int], fn1: Path, fn2: Path): 41 | overlay = np.zeros((*im1.shape, 3), dtype=im1.dtype) 42 | rows = slice(Ul[0], Ul[0] + im2.shape[0]) 43 | cols = slice(Ul[1], Ul[1] + im2.shape[1]) 44 | overlay[:, :, 0] = im1 45 | overlay[rows, cols, 2] = im2 46 | 47 | # the diff may not be precisely zero everywhere if the crop did filtering 48 | diff = overlay[rows, cols, 0] - overlay[rows, cols, 2] 49 | print("sum(im1-im2) over ROI is:", diff.sum()) 50 | 51 | fg = figure() 52 | axs: typing.Any = fg.subplots(1, 2) 53 | h1 = axs[0].imshow(overlay, alpha=0.6) 54 | axs[0].set_title(f"overlay: original {fn1.name}:red crop {fn2.name}:blue", wrap=True) 55 | fg.colorbar(h1, ax=axs[0]) 56 | 57 | h2 = axs[1].imshow(diff) 58 | axs[1].set_title("im1 - im2 over ROI") 59 | fg.colorbar(h2, ax=axs[1]) 60 | 61 | fg.suptitle(f"upper left corner of crop on original image: {Ul}") 62 | 63 | 64 | if __name__ == "__main__": 65 | p = argparse.ArgumentParser() 66 | p.add_argument("fn1", help="original large image") 67 | p.add_argument("fn2", help="cropped smaller image") 68 | P = p.parse_args() 69 | 70 | fn1 = Path(P.fn1).expanduser() 71 | fn2 = Path(P.fn2).expanduser() 72 | 73 | im1, im2 = load_gray(fn1, fn2) 74 | 75 | Ul = find_crop(im1, im2) 76 | print(f"upper left corner (pixel indices) of {fn2.name} in {fn1.name} is {Ul}") 77 | 78 | plot_overlay(im1, im2, Ul, fn1, fn2) 79 | show() 80 | -------------------------------------------------------------------------------- /downloadIndex.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | http://astrometry.net/doc/readme.html#getting-index-files 4 | 5 | Downloads 2MASS whole-sky index files, which worked well for me with a 6 | variety of non-all-sky auroral imagagers in the 5 to 50 degree FOV range. 7 | Also, the Tycho index files are good for this FOV range and I sometimes need them too. 8 | """ 9 | 10 | from __future__ import annotations 11 | from argparse import ArgumentParser 12 | from pathlib import Path 13 | import urllib.request 14 | import urllib.error 15 | 16 | url_2mass = "http://broiler.astrometry.net/~dstn/4200/" 17 | url_tycho = "http://broiler.astrometry.net/~dstn/4100/" 18 | 19 | 20 | def download(odir: Path, source_url: str, irng: list[int]): 21 | """Download star index files. 22 | The default range was useful for my cameras. 23 | """ 24 | 25 | assert len(irng) == 2, "specify start, stop indices" 26 | 27 | odir = Path(odir).expanduser() 28 | odir.mkdir(parents=True, exist_ok=True) 29 | 30 | ri = int(source_url.split("/")[-2][:2]) 31 | 32 | for i in range(irng[0], irng[1] + 1): 33 | fn = f"index-{ri:2d}{i:02d}.fits" 34 | url = f"{source_url}{fn}" 35 | ofn = odir / fn 36 | if ofn.is_file(): # no clobber 37 | print("Exists:", ofn) 38 | continue 39 | print(f"{url} => {ofn}") 40 | 41 | url_retrieve(url, ofn) 42 | 43 | 44 | def url_retrieve(url: str, outfile: Path, overwrite: bool = False): 45 | """ 46 | Parameters 47 | ---------- 48 | url: str 49 | URL to download from 50 | outfile: pathlib.Path 51 | output filepath (including name) 52 | overwrite: bool 53 | overwrite if file exists 54 | """ 55 | 56 | outfile = Path(outfile).expanduser().resolve() 57 | if outfile.is_dir(): 58 | raise ValueError("Please specify full filepath, including filename") 59 | # need .resolve() in case intermediate relative dir doesn't exist 60 | if overwrite or not outfile.is_file(): 61 | outfile.parent.mkdir(parents=True, exist_ok=True) 62 | urllib.request.urlretrieve(url, outfile) 63 | 64 | 65 | p = ArgumentParser() 66 | p.add_argument("-o", "--outdir", help="directory to save index files", default="~/astrometry-data") 67 | p.add_argument("-source", nargs="+", default=[url_2mass, url_tycho]) 68 | p.add_argument( 69 | "-i", 70 | "--indexrange", 71 | help="start,stop (inclusive) index range", 72 | nargs=2, 73 | type=int, 74 | default=(8, 19), 75 | ) 76 | P = p.parse_args() 77 | 78 | for s in P.source: 79 | download(P.outdir, s, P.indexrange) 80 | -------------------------------------------------------------------------------- /archive/modifyFITSheaderAstrometry.m: -------------------------------------------------------------------------------- 1 | function [Xind, Yind, RAdeg, DECdeg ] = modifyFITSheaderAstrometry(WCSfn,xPix,yPix,debugp) 2 | % used with Astrometry.net program to make a FITS file with the desired data 3 | % inputs: 4 | % ------- 5 | % WCSfn: the .wcs file astrometry solve-field makes 6 | % xPix: number of pixels in x-dimension 7 | % yPix: number of pixels in y-dimension 8 | % debugp: print verbose messages 9 | % 10 | % Michael Hirsch 11 | % GPL v3+ license 12 | % 13 | % example: 14 | % modifyFITSheaderAstrometry('../Meteor/HST1_1secNoEM.wcs',512,512) 15 | % modifyFITSheaderAstrometry('../Meteor/HST2_1secNoEM.wcs',512,512) 16 | %------------ 17 | if nargin<4, debugp = false; end 18 | binpath = '/usr/bin/' 19 | %% setup filenames 20 | [dataDir,fnStem] = fileparts(WCSfn); 21 | if strcmp(dataDir(1),'~') 22 | dataDir = [getenv('HOME'),dataDir(2:end)]; 23 | end 24 | XYfn = [dataDir,'/',fnStem,'.CamXY']; 25 | OutFN = [dataDir,'/',fnStem,'.CamRaDec']; 26 | 27 | if strcmp(WCSfn(1),'~') %replace tilde if present 28 | WCSfn = [getenv('HOME'),WCSfn(2:end)]; 29 | end %if 30 | 31 | if exist(XYfn,'file') 32 | newCamXY = tempname; 33 | display(['Moving existing ',XYfn,' to ',newCamXY]) 34 | movefile(XYfn,newCamXY); 35 | end 36 | %% setup the FITS table 37 | import matlab.io.* 38 | FPTR = fits.createFile(XYfn); 39 | TBLTYPE = 'binary'; 40 | NROWS = 0; 41 | 42 | TTYPE = {'X', 'Y'}; 43 | TFORM = {'1D','1D'}; %using double precision float for coordinates 44 | TUNIT = {'pixel','pixel'}; 45 | 46 | fits.createTbl(FPTR,TBLTYPE,NROWS,TTYPE,TFORM,TUNIT,'Image plane x-y coordinates'); 47 | %% create data to write to table 48 | % we're going to put in all possible x-y coordinates 49 | [xData,yData] = meshgrid(1:1:xPix,1:1:yPix); 50 | 51 | %to save time verifying program behavior, for a first pass I'm going to reshape 52 | %everything into columns -- sorry if that's annoying ... 53 | xData = xData(:); 54 | yData = yData(:); 55 | %% insert data into FITS table 56 | % 1,1 means write into column 1, STARTING at row 1. 57 | fits.writeCol(FPTR,1,1,xData); 58 | 59 | % 2,1 means write into column 2, STARTING at row 1. 60 | fits.writeCol(FPTR,2,1,yData); 61 | 62 | %% write FITS to file 63 | fits.closeFile(FPTR); 64 | 65 | %% get RA/DEC from Astrometry.net 66 | ucmd = [binpath,'wcs-xy2rd -w ',WCSfn,' -i ',XYfn,' -o ',OutFN,' -X X -Y Y -v']; 67 | display(ucmd) 68 | [uerr,umsg] = unix(ucmd); 69 | if uerr, 70 | display(umsg) 71 | error('problem with wcs-xy2rd -- does "which wcs-xy2rd" return anything?'), 72 | end 73 | 74 | %% test reading table 75 | if debugp 76 | fitsdisp(XYfn) %get the header 77 | end 78 | 79 | XYind = cell2mat(fitsread(XYfn,'binarytable')); %get the data 80 | Xind = XYind(:,1); 81 | Yind = XYind(:,2); 82 | 83 | RADECdeg = cell2mat(fitsread(OutFN,'binarytable')); %get the data 84 | RAdeg = RADECdeg(:,1); 85 | DECdeg = RADECdeg(:,2); 86 | 87 | %cleanup 88 | if nargout==0, clear, end 89 | end 90 | -------------------------------------------------------------------------------- /scripts/OverlayStars.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Overlay stars from a catalog on an image. 5 | This is like a closed loop check that solve-field output (here, the .rdls file) makes sense. 6 | 7 | Procedure starting with a .fits file to solve: 8 | 9 | 1. Use --tag-all option of solve-field to output star magnitude in the .rdls file: 10 | 11 | solve-field src/astrometry_azel/tests/apod4.fits --tag-all 12 | 13 | 2. Run this script to overlay stars on the image -- warped and unwarped. 14 | 15 | python scripts/OverlayStars.py src/astrometry_azel/tests/apod4 16 | """ 17 | 18 | import argparse 19 | from pathlib import Path 20 | 21 | import numpy as np 22 | 23 | from astrometry_azel.io import get_sources 24 | from astrometry_azel.plot import wcs_image, xy_image 25 | 26 | from matplotlib.pyplot import figure, show 27 | 28 | 29 | def label_stars(ax, x, y, mag_norm=None): 30 | if mag_norm is not None: 31 | ax.scatter( 32 | x, 33 | y, 34 | s=100 * mag_norm, 35 | edgecolors="red", 36 | marker="o", 37 | facecolors="none", 38 | label="stars", 39 | ) 40 | else: 41 | ax.scatter( 42 | x, 43 | y, 44 | s=25, 45 | edgecolors="red", 46 | marker="o", 47 | facecolors="none", 48 | label="stars", 49 | ) 50 | 51 | 52 | p = argparse.ArgumentParser() 53 | p.add_argument( 54 | "stem", help="FITS file stem (without .suffix) output from solve-field (Astrometry.net)" 55 | ) 56 | p = p.parse_args() 57 | 58 | stem = Path(p.stem) 59 | new = stem.with_suffix(".new") 60 | 61 | rdls = stem.with_suffix(".rdls") 62 | source_ra = get_sources(rdls) 63 | 64 | 65 | xyls = stem.parent / (stem.name + "-indx.xyls") 66 | source_xy = get_sources(xyls) 67 | 68 | if "MAG" in source_ra.columns.names: 69 | Ntot = source_ra.shape[0] 70 | Nkeep = 20 # only plot the brightest stars 71 | 72 | i = source_ra.argsort(axis=0, order="MAG") 73 | 74 | source_ra = np.take_along_axis(source_ra, i, axis=0)[:Nkeep] 75 | source_xy = np.take_along_axis(source_xy, i, axis=0)[:Nkeep] 76 | 77 | # normalize star magnitude to [0,1] 78 | mag_norm = (source_ra["MAG"] - source_ra["MAG"].min()) / np.ptp(source_ra["MAG"]) 79 | ttxt = f"{stem}: {source_ra.shape[0]} / {Ntot} stars" 80 | else: 81 | mag_norm = None 82 | ttxt = f"{stem} {source_ra.shape[0]} stars" 83 | 84 | # %% unwarped image 85 | 86 | fgy = figure() 87 | axy = fgy.gca() 88 | xy_image(new, "gray", axy) 89 | 90 | label_stars(axy, source_xy["X"], source_xy["Y"], mag_norm) 91 | axy.set_title(ttxt, wrap=True) 92 | 93 | # %% WCS warped image 94 | 95 | fgr = figure() 96 | axr = fgr.gca() 97 | wcs_image(new, "gray", axr) 98 | 99 | label_stars(axr, source_ra["RA"], source_ra["DEC"], mag_norm) 100 | axr.set_title(ttxt, wrap=True) 101 | 102 | show() 103 | -------------------------------------------------------------------------------- /src/astrometry_azel/plot/project.py: -------------------------------------------------------------------------------- 1 | import xarray 2 | import numpy as np 3 | import typing 4 | 5 | from matplotlib.pyplot import figure 6 | from matplotlib.colors import LogNorm 7 | import cartopy 8 | 9 | 10 | def geomap(img: xarray.Dataset, minimum_elevation: float = 0.0): 11 | """ 12 | plot geomapped image 13 | 14 | Parameters 15 | ---------- 16 | 17 | img: xarray.Dataset 18 | image data and coordinates 19 | minimum_elevation: float 20 | minimum elevation angle to mask (degrees) 21 | """ 22 | 23 | projection_altitude_km = img["latitude_proj"].attrs["projection_altitude_km"] 24 | 25 | proj = cartopy.crs.PlateCarree() 26 | 27 | elevation_mask = img.elevation.data < minimum_elevation 28 | masked = np.ma.masked_array(img.image, mask=elevation_mask) # type: ignore 29 | 30 | # fg2 = figure() 31 | # axi = fg2.add_subplot() 32 | # axi.pcolormesh(masked, norm=LogNorm()) 33 | # show() 34 | 35 | fg = figure() 36 | 37 | ax: typing.Any = fg.add_subplot(projection=proj) 38 | 39 | # plot observer 40 | ax.scatter(img.observer_longitude, img.observer_latitude, transform=proj, color="r", marker="*") 41 | 42 | hgl = ax.gridlines(crs=proj, color="gray", linestyle="--", linewidth=0.5) 43 | 44 | # features to give human sense of maps 45 | features = { 46 | "Thunder Mountain": (52.6, -124.27), 47 | "Calgary": (51.05, -114.08), 48 | "Banff": (51.18, -115.57), 49 | "Edmonton": (53.55, -113.49), 50 | } 51 | ax.add_feature(cartopy.feature.COASTLINE) 52 | ax.add_feature(cartopy.feature.BORDERS) 53 | ax.add_feature(cartopy.feature.LAND) 54 | 55 | states_provinces = cartopy.feature.NaturalEarthFeature( 56 | category="cultural", name="admin_1_states_provinces_lines", scale="50m", facecolor="none" 57 | ) 58 | ax.add_feature(states_provinces, edgecolor="gray", linewidth=0.5) 59 | 60 | for k, v in features.items(): 61 | ax.scatter(v[1], v[0], transform=proj, color="grey", marker="o", alpha=0.8) 62 | ax.text(v[1], v[0], k, transform=proj, alpha=0.8) 63 | 64 | # prettify figure 65 | latitude = np.ma.masked_array(img.latitude_proj, mask=elevation_mask) # type: ignore 66 | longitude = np.ma.masked_array(img.longitude_proj, mask=elevation_mask) # type: ignore 67 | lat_bounds = (latitude.min() - 0.5, latitude.max() + 0.5) 68 | lon_bounds = (longitude.min() - 0.5, longitude.max() + 0.5) 69 | hgl.bottom_labels = True 70 | hgl.left_labels = True 71 | 72 | ax.pcolormesh(img.longitude_proj, img.latitude_proj, masked, norm=LogNorm(), cmap="Greys_r") 73 | 74 | ax.set_title( 75 | f"{str(img.time.values)[:-10]} " 76 | f"Projection alt. (km): {projection_altitude_km} " 77 | f"Min. Elv. (deg): {minimum_elevation}", 78 | wrap=True, 79 | ) 80 | ax.set_xlabel("geographic longitude") 81 | ax.set_ylabel("geographic latitude") 82 | 83 | lims = (*lon_bounds, *lat_bounds) 84 | ax.set_extent(lims) 85 | 86 | return fg 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Azimuth/Elevation converter for [Astrometry.net](https://github.com/dstndstn/astrometry.net) 3 | 4 | [![Zenodo DOI](https://zenodo.org/badge/19366614.svg)](https://zenodo.org/badge/latestdoi/19366614) 5 | [![ci](https://github.com/space-physics/astrometry_geomap/actions/workflows/ci.yml/badge.svg)](https://github.com/space-physics/astrometry_geomap/actions/workflows/ci.yml) 6 | [![PyPI version](https://img.shields.io/pypi/pyversions/astrometry-azel.svg)](https://pypi.python.org/pypi/astrometry-azel) 7 | [![Downloads](http://pepy.tech/badge/astrometry-azel)](http://pepy.tech/project/astrometry-azel) 8 | 9 | [Tips and techniques article](https://www.scivision.dev/astrometry-tips-techniques), especially for DSLR citizen science data. 10 | 11 | Get 12 | [Astrometry.net ≥ 0.67](https://scivision.dev/astrometry-install-usage) 13 | or use the 14 | [astrometry.net cloud service](http://nova.astrometry.net/upload). 15 | 16 | ```sh 17 | python3 -m pip install -e . 18 | ``` 19 | 20 | The main program used is [PlateScale.py](./PlateScale.py). 21 | Auxiliary scripts are under [scripts/](./scripts/) 22 | 23 | ## PlateScale.py 24 | 25 | The main script most users would use to register a star field image to Azimuth and Elevation is "PlateScale.py". 26 | 27 | The "--args" command line option allows passing through a variety of parameters to `solve-field`, which underlies this program. 28 | Type `solve-field -h` or `man solve-field` for a brief description of the nearly 100 options available. 29 | 30 | Be sure to enclose the options in quotes. 31 | For example, to specify that the image field is at least 20 degrees in extent: 32 | 33 | ```sh 34 | python PlateScale.py ~/data/myimg.jpg --args "--scale-low 20" 35 | ``` 36 | 37 | Citizen science images often contain extraneous items in the image field of view. 38 | These can very easily break `solve-field`, which is designed for professional science-grade imagery from telescopes and narrow to medium field of view imagers (at least to 50 degree FOV). 39 | To mitigate these issues, judicious use of arguments passed to `solve-field` via `--args` is probably a good start. 40 | 41 | The parameters I find most useful for citizen science images include: 42 | 43 | ``` 44 | --scale-low : lower bound of image scale estimate 45 | 46 | --scale-high : upper bound of image scale estimate 47 | 48 | --depth : number of field objects to look at, or range 49 | of numbers; 1 is the brightest star, so "-d 10" or "-d 1-10" mean look 50 | at the top ten brightest stars only. 51 | ``` 52 | 53 | For extraneous regions of the image, try making a copy of the original image that has the offending regions cropped out. 54 | If the original image is in a lossy format such as JPEG, consider saving in a lossless format such as PNG after cropping. 55 | 56 | ### Astrometry.net installed on local computer 57 | 58 | ```sh 59 | python PlateScale.py myimg.fits 61.2 -149.9 2013-04-02T12:03:23Z 60 | ``` 61 | 62 | gives netCDF .nc with az/el ra/dec and PNG plots of the data. 63 | Both files contain the same data, just for your convenience. 64 | 65 | 61.2 -149.9 is your WGS84 coordinates, 2013-04-02T12:03:23Z is UTC time of the picture. 66 | 67 | ### wcs.fits from the Astrometry.net website 68 | 69 | Download from nova.astrometry.net solved image the "new-image.fits" and "wcs.fits" files, then: 70 | 71 | ```sh 72 | python PlateScale.py 61.2 -149.9 2013-04-02T12:03:23Z new-image.fits 73 | ``` 74 | 75 | ## Notes 76 | 77 | * 2MASS [index](http://broiler.astrometry.net/~dstn/4200/) 78 | * Tycho [index](http://broiler.astrometry.net/~dstn/4100/) 79 | 80 | * ways to [use astrometry.net](http://astrometry.net/use.html) 81 | * astrometry.net [source code releases](http://astrometry.net/downloads/) 82 | * astrometry.net [GitHub](https://github.com/dstndstn/astrometry.net) 83 | 84 | * [article](https://www.dsi.uni-stuttgart.de/institut/mitarbeiter/schindler/Schindler_et_al._2016.pdf) on good robustness of Astrometry.net to shaky, streaked images. 85 | 86 | ### Download star index files 87 | 88 | ```sh 89 | python downloadIndex.py 90 | ``` 91 | 92 | Edit file /etc/astrometry.cfg or similar: 93 | 94 | Be sure `add_path` points to /home/username/astrometry-data, where username is your Linux username. 95 | Don't use ~ or $HOME. 96 | 97 | Uncomment `inparallel` to process much faster. 98 | 99 | Optionally, set `minwidth` smaller than the smallest FOV (in degrees) expected. 100 | For example, if NOT using a telescope, perhaps minwidth 1 or something. 101 | 102 | ## PlotGeomap.py 103 | 104 | Plot an image registered to Latitude and Longitude, assuming the image features all occurred at a single altitude. 105 | This technique is used in aeronomy assuming a certain altitude of auroral or airglow emissions. 106 | This approximation is based on colors representing particle dynamics at a range of altitudes, approximated by a single altitude. 107 | For example, if a short wavelength filter (blue) was applied to the auroral image, one might assume the emissions were at about 100 km altitude. 108 | 109 | ## Related 110 | 111 | For source extraction or photometry, see my AstroPy-based 112 | [examples](https://github.com/scivision/starscale). 113 | -------------------------------------------------------------------------------- /src/astrometry_azel/io.py: -------------------------------------------------------------------------------- 1 | """ 2 | Image stack -> average -> write FITS 3 | 4 | 5 | average the specified frames then write a FITS file. 6 | Averaging a selected stack of images improves SNR for astrometry.net 7 | """ 8 | 9 | from __future__ import annotations 10 | from pathlib import Path 11 | import numpy as np 12 | from datetime import datetime 13 | import logging 14 | 15 | from astropy.io import fits 16 | import xarray 17 | 18 | try: 19 | import imageio.v3 as iio 20 | except ImportError: 21 | imageio = None # type: ignore 22 | try: 23 | import h5py 24 | except ImportError: 25 | h5py = None 26 | try: 27 | from scipy.io import loadmat 28 | except ImportError: 29 | loadmat = None 30 | 31 | 32 | def get_sources(fn: Path): 33 | """ 34 | read source (star) coordinates from .rdls file 35 | 36 | Parameters 37 | ---------- 38 | 39 | fn: pathlib.Path 40 | .rdls file computed by astrometry.net solve-field (astrometry_azel.doSolve()) 41 | 42 | Returns 43 | ------- 44 | 45 | radec: astropy.fits.fitsrec.FITS_rec 46 | RA, Dec of sources 47 | """ 48 | 49 | fn = Path(fn).expanduser().resolve(strict=True) 50 | with fits.open(fn, "readonly") as f: 51 | return f[1].data 52 | 53 | 54 | def rgb2grey(rgb_img): 55 | """ 56 | rgb_img: ndarray 57 | RGB image 58 | from PySumix rgb2gray.py 59 | """ 60 | 61 | ndim = rgb_img.ndim 62 | if ndim == 2: 63 | logging.info("assuming it's already gray since ndim=2") 64 | grey_img = rgb_img 65 | elif ndim == 3 and rgb_img.shape[-1] == 3: # this is the normal case 66 | grey_img = np.around(rgb_img[..., :] @ [0.299, 0.587, 0.114]).astype(rgb_img.dtype) 67 | elif ndim == 3 and rgb_img.shape[-1] == 4: 68 | logging.info("assuming this is an RGBA image, discarding alpha channel") 69 | grey_img = rgb2grey(rgb_img[..., :-1]) 70 | 71 | return grey_img 72 | 73 | 74 | def read_data(in_file: Path): 75 | img = xarray.open_dataset(in_file) 76 | img["image"] = (("y", "x"), load_image(img.filename)) 77 | 78 | return img 79 | 80 | 81 | def load_image(file: Path): 82 | """ 83 | load netCDF from PlateScale.py and original image 84 | 85 | Parameters 86 | ---------- 87 | 88 | file: pathlib.Path 89 | netCDF file from PlateScale.py 90 | """ 91 | 92 | file = Path(file).expanduser().resolve(strict=True) 93 | 94 | if file.suffix == ".fits": 95 | with fits.open(file, mode="readonly", memmap=False) as f: 96 | image = f[0].data 97 | else: 98 | image = rgb2grey(iio.imread(file)) 99 | 100 | return image 101 | 102 | 103 | def meanstack( 104 | infn: Path, Navg: slice | int, ut1: datetime | None = None, method: str = "mean" 105 | ) -> tuple: 106 | infn = Path(infn).expanduser().resolve() 107 | if not infn.is_file(): 108 | raise FileNotFoundError(infn) 109 | 110 | # %% parse indicies to load 111 | if isinstance(Navg, slice): 112 | key = Navg 113 | elif isinstance(Navg, int): 114 | key = slice(0, Navg) 115 | elif len(Navg) == 1: 116 | key = slice(0, Navg[0]) 117 | elif len(Navg) == 2: 118 | key = slice(Navg[0], Navg[1]) 119 | else: 120 | raise ValueError(f"not sure how to handle Navg={Navg}") 121 | # %% load images 122 | """ 123 | some methods handled individually to improve efficiency with huge files 124 | """ 125 | if infn.suffix == ".h5": 126 | if h5py is None: 127 | raise ImportError("pip install h5py") 128 | img, ut1 = _h5mean(infn, ut1, key, method) 129 | elif infn.suffix in {".fits", ".new"}: 130 | # mmap doesn't work with BZERO/BSCALE/BLANK 131 | with fits.open(infn, mode="readonly", memmap=False) as f: 132 | img = collapsestack(f[0].data, key, method) 133 | elif infn.suffix == ".mat": 134 | if loadmat is None: 135 | raise ImportError("pip install scipy") 136 | img = loadmat(infn) 137 | img = collapsestack(img["data"].T, key, method) # matlab is fortran order 138 | else: # .tif etc. 139 | if imageio is None: 140 | raise ImportError("pip install imageio") 141 | img = imageio.imread(infn, as_gray=True) 142 | if img.ndim in {3, 4} and img.shape[-1] == 3: # assume RGB 143 | img = collapsestack(img, key, method) 144 | 145 | return img, ut1 146 | 147 | 148 | def _h5mean(fn: Path, ut1: datetime | None, key: slice, method: str) -> tuple: 149 | with h5py.File(fn, "r") as f: 150 | img = collapsestack(f["/rawimg"], key, method) 151 | # %% time 152 | if ut1 is None: 153 | try: 154 | ut1 = f["/ut1_unix"][key][0] 155 | except KeyError: 156 | pass 157 | # %% orientation 158 | try: 159 | img = np.rot90(img, k=f["/params"]["rotccw"]) 160 | except KeyError: 161 | pass 162 | 163 | return img, ut1 164 | 165 | 166 | def collapsestack(img, key: slice, method: str): 167 | if img.ndim not in {2, 3, 4}: 168 | raise ValueError("only 2D, 3D, or 4D image stacks are handled") 169 | 170 | # %% 2-D 171 | if img.ndim == 2: 172 | return img 173 | # %% 3-D 174 | if method == "mean": 175 | func = np.mean 176 | elif method == "median": 177 | func = np.median # type: ignore 178 | else: 179 | raise TypeError(f"unknown method {method}") 180 | 181 | colaps = func(img[key, ...], axis=0).astype(img.dtype) 182 | assert colaps.ndim > 0 183 | assert isinstance(colaps, np.ndarray) 184 | 185 | return colaps 186 | 187 | 188 | def write_netcdf(ds: xarray.Dataset, out_file: Path) -> None: 189 | enc = {} 190 | 191 | for k in ds.data_vars: 192 | if ds[k].ndim < 2: 193 | continue 194 | 195 | enc[k] = { 196 | "zlib": True, 197 | "complevel": 3, 198 | "fletcher32": True, 199 | "chunksizes": tuple(map(lambda x: x // 2, ds[k].shape)), 200 | # arbitrary, little impact on compression 201 | } 202 | 203 | ds.to_netcdf(out_file, format="NETCDF4", engine="netcdf4", encoding=enc) 204 | 205 | 206 | def write_fits(img, outfn: Path) -> None: 207 | f = fits.PrimaryHDU(img) 208 | 209 | f.writeto(outfn, overwrite=True, checksum=True) 210 | # no close() 211 | print("writing", outfn) 212 | 213 | 214 | def readh5coord(fn: Path) -> tuple[float, float] | None: 215 | with h5py.File(fn, "r") as f: 216 | try: 217 | latlon = (f["/sensorloc"]["glat"], f["/sensorloc"]["glon"]) 218 | except KeyError: 219 | try: 220 | latlon = f["/lla"][:2] 221 | except KeyError: 222 | raise KeyError(f"could not find lat/lon in {fn}") 223 | 224 | return latlon 225 | -------------------------------------------------------------------------------- /src/astrometry_azel/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from datetime import datetime 3 | from datetime import timezone as tz 4 | import functools 5 | import shutil 6 | import subprocess 7 | 8 | import numpy as np 9 | import xarray 10 | 11 | import logging 12 | from dateutil.parser import parse 13 | 14 | from astropy.time import Time 15 | from astropy.coordinates import AltAz, Angle, EarthLocation, SkyCoord 16 | from astropy import units as u 17 | from astropy.io import fits 18 | import astropy.wcs as awcs 19 | 20 | __all__ = ["fits2azel", "fits2radec", "radec2azel", "doSolve"] 21 | 22 | __version__ = "1.4.0" 23 | 24 | 25 | def fits2radec(fitsfn: Path, solve: bool = False, args: str = ""): 26 | """ 27 | get RA, Decl from FITS file 28 | """ 29 | fitsfn = Path(fitsfn).expanduser() 30 | 31 | if solve: 32 | doSolve(fitsfn, args) 33 | 34 | with fits.open(fitsfn, mode="readonly") as f: 35 | yPix, xPix = f[0].shape[-2:] 36 | 37 | x, y = np.meshgrid(range(xPix), range(yPix)) 38 | # pixel indices to find RA/dec of 39 | xy = np.column_stack((x.ravel(order="C"), y.ravel(order="C"))) 40 | 41 | if not (wcsfn := fitsfn.with_suffix(".wcs")).is_file(): 42 | if not (wcsfn := fitsfn.with_name("wcs.fits")).is_file(): 43 | raise FileNotFoundError(f"could not find WCS file for {fitsfn}") 44 | with fits.open(wcsfn, mode="readonly") as f: 45 | # %% use astropy.wcs to register pixels to RA/DEC 46 | # http://docs.astropy.org/en/stable/api/astropy.wcs.WCS.html#astropy.wcs.WCS 47 | # NOTE: it's normal to get this warning: 48 | # WARNING: FITSFixedWarning: The WCS transformation has more axes (2) than the image it is associated with (0) [astropy.wcs.wcs] 49 | if f[0].header["WCSAXES"] == 2: 50 | # greyscale image 51 | radec = awcs.wcs.WCS(f[0].header).all_pix2world(xy, 0) 52 | elif f[0].header["WCSAXES"] == 3: 53 | # color image 54 | radec = awcs.wcs.WCS(f[0].header, naxis=[0, 1]).all_pix2world(xy, 0) 55 | else: 56 | raise ValueError(f"{fitsfn} has {f[0].header['NAXIS']} axes -- expected 2 or 3") 57 | 58 | ra = radec[:, 0].reshape((yPix, xPix), order="C") 59 | dec = radec[:, 1].reshape((yPix, xPix), order="C") 60 | # %% collect output 61 | radec = xarray.Dataset( 62 | {"ra": (("y", "x"), ra), "dec": (("y", "x"), dec)}, 63 | {"x": range(xPix), "y": range(yPix)}, 64 | attrs={"filename": str(fitsfn)}, 65 | ) 66 | 67 | radec["ra"].attrs["units"] = "Right Ascension degrees east" 68 | radec["dec"].attrs["units"] = "Declination degrees north" 69 | radec["x"].attrs["units"] = "pixel index" 70 | radec["y"].attrs["units"] = "pixel index" 71 | 72 | return radec 73 | 74 | 75 | def fits2azel( 76 | fitsfn: Path, 77 | *, 78 | latlon: tuple[float, float], 79 | time: datetime, 80 | solve: bool = False, 81 | args: str = "", 82 | ): 83 | fitsfn = Path(fitsfn).expanduser() 84 | 85 | radec = fits2radec(fitsfn, solve, args) 86 | 87 | return radec2azel(radec, latlon, time) 88 | 89 | 90 | def radec2azel(scale: xarray.Dataset, latlon: tuple[float, float], time: datetime): 91 | """ 92 | right ascension/declination to azimuth/elevation 93 | """ 94 | 95 | if isinstance(time, datetime): 96 | pass 97 | elif isinstance(time, (float, int)): # assume UT1_Unix 98 | time = datetime.fromtimestamp(time, tz=tz.UTC) 99 | elif isinstance(time, str): 100 | time = parse(time) 101 | else: 102 | raise TypeError(f"expected datetime, float, int, or str -- got {type(time)}") 103 | 104 | print("image time:", time) 105 | # %% knowing camera location, time, and sky coordinates observed, convert to az/el for each pixel 106 | # .values is to avoid silently freezing AstroPy 107 | 108 | az, el = pymap3d_radec2azel(scale["ra"].values, scale["dec"].values, *latlon, time) 109 | if (el < 0).any(): 110 | Nbelow = (el < 0).nonzero() 111 | logging.error( 112 | f"{Nbelow} points were below the horizon." 113 | "Currently this program assumed observer ~ ground level." 114 | ) 115 | 116 | # %% collect output 117 | scale["azimuth"] = (("y", "x"), az) 118 | scale["azimuth"].attrs["units"] = "degrees clockwise from north" 119 | 120 | scale["elevation"] = (("y", "x"), el) 121 | scale["elevation"].attrs["units"] = "degrees above horizon" 122 | 123 | scale["observer_latitude"] = latlon[0] 124 | scale["observer_latitude"].attrs["units"] = "degrees north WGS84" 125 | 126 | scale["observer_longitude"] = latlon[1] 127 | scale["observer_longitude"].attrs["units"] = "degrees east WGS84" 128 | 129 | # datetime64 can be saved to netCDF4, but Python datetime.datetime cannot 130 | scale["time"] = np.datetime64(time) 131 | 132 | return scale 133 | 134 | 135 | def pymap3d_radec2azel( 136 | ra_deg, 137 | dec_deg, 138 | lat_deg, 139 | lon_deg, 140 | time: datetime, 141 | ) -> tuple: 142 | """ 143 | sky coordinates (ra, dec) to viewing angle (az, el) 144 | 145 | Parameters 146 | ---------- 147 | ra_deg : float 148 | ecliptic right ascension (degress) 149 | dec_deg : float 150 | ecliptic declination (degrees) 151 | lat_deg : float 152 | observer latitude [-90, 90] 153 | lon_deg : float 154 | observer longitude [-180, 180] (degrees) 155 | time : datetime.datetime 156 | time of observation 157 | 158 | Returns 159 | ------- 160 | az_deg : float 161 | azimuth [degrees clockwize from North] 162 | el_deg : float 163 | elevation [degrees above horizon (neglecting aberration)] 164 | """ 165 | 166 | obs = EarthLocation(lat=lat_deg * u.deg, lon=lon_deg * u.deg) 167 | points = SkyCoord(Angle(ra_deg, unit=u.deg), Angle(dec_deg, unit=u.deg), equinox="J2000.0") 168 | altaz = points.transform_to(AltAz(location=obs, obstime=Time(time))) 169 | 170 | return altaz.az.degree, altaz.alt.degree 171 | 172 | 173 | @functools.cache 174 | def get_solve_exe() -> str: 175 | if not (solve := shutil.which("solve-field")): 176 | raise FileNotFoundError("Astrometry.net solve-file exectuable not found") 177 | 178 | return solve 179 | 180 | 181 | def doSolve(fitsfn: Path, args: str = "") -> None: 182 | """ 183 | run Astrometry.net solve-field from Python 184 | """ 185 | 186 | fitsfn = Path(fitsfn).expanduser() 187 | if not fitsfn.is_file(): 188 | raise FileNotFoundError(f"{fitsfn} not found") 189 | 190 | # %% build command 191 | cmd = [get_solve_exe(), "--overwrite", str(fitsfn), "--verbose"] 192 | if args: 193 | # if args is a string, split it. Don't append an empty space or solve-field CLI fail 194 | cmd += args.split(" ") 195 | print("\n", " ".join(cmd), "\n") 196 | # %% execute 197 | # bufsize=1: line-buffered 198 | with subprocess.Popen(cmd, stdout=subprocess.PIPE, bufsize=1, text=True) as p: 199 | if p.stdout: 200 | for line in p.stdout: 201 | print(line, end="") 202 | 203 | if "Did not solve" in line: 204 | raise RuntimeError(f"could not solve {fitsfn}") 205 | -------------------------------------------------------------------------------- /src/astrometry_azel/plot/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import logging 3 | import typing 4 | 5 | import numpy as np 6 | from astropy.io import fits 7 | from astropy.wcs import wcs 8 | 9 | from matplotlib.figure import Figure 10 | from matplotlib.colors import LogNorm 11 | 12 | 13 | def az_el(scale, plottype: str = "singlecontour", img=None): 14 | """ 15 | plot azimuth and elevation mapped to sky 16 | """ 17 | if plottype == "singlecontour": 18 | fg: typing.Any = Figure() 19 | ax: typing.Any = fg.gca() 20 | if img is not None: 21 | ax.imshow(img, origin="lower", cmap="gray") 22 | cs = ax.contour(scale["x"], scale["y"], scale["azimuth"]) 23 | ax.clabel(cs, inline=1, fmt="%0.1f") 24 | cs = ax.contour(scale["x"], scale["y"], scale["elevation"]) 25 | ax.clabel(cs, inline=True, fmt="%0.1f") 26 | ax.set_xlabel("x-pixel") 27 | ax.set_ylabel("y-pixel") 28 | ax.set_title( 29 | f"{Path(scale.filename).name} ({scale.observer_latitude:.2f}, {scale.observer_longitude:.2f})" 30 | f" {scale.time} Azimuth / Elevation", 31 | wrap=True, 32 | ) 33 | fg.set_tight_layout(True) 34 | return fg 35 | elif plottype == "image": 36 | fg = Figure(figsize=(12, 5)) 37 | ax = fg.subplots(1, 2, sharey=True) 38 | hia = ax[0].imshow(scale["azimuth"], origin="lower") 39 | hc = fg.colorbar(hia) 40 | hc.set_label("Azimuth [deg]") 41 | elif plottype == "contour": 42 | fg = Figure(figsize=(12, 5)) 43 | ax = fg.subplots(1, 2, sharey=True) 44 | if img is not None: 45 | ax[0].imshow(img, origin="lower", cmap="gray") 46 | cs = ax[0].contour(scale["x"], scale["y"], scale["azimuth"]) 47 | ax[0].clabel(cs, inline=1, fmt="%0.1f") 48 | 49 | ax[0].set_xlabel("x-pixel") 50 | ax[0].set_ylabel("y-pixel") 51 | ax[0].set_title("azimuth") 52 | # %% 53 | axe = ax[1] 54 | if plottype == "image": 55 | hie = axe.imshow(scale["elevation"], origin="lower") 56 | hc = fg.colorbar(hie) 57 | hc.set_label("Elevation [deg]") 58 | elif plottype == "contour": 59 | if img is not None: 60 | axe.imshow(img, origin="lower", cmap="gray") 61 | cs = axe.contour(scale["x"], scale["y"], scale["elevation"]) 62 | axe.clabel(cs, inline=True, fmt="%0.1f") 63 | 64 | axe.set_xlabel("x-pixel") 65 | axe.set_title("elevation") 66 | fg.suptitle( 67 | f"{Path(scale.filename).name} ({scale.observer_latitude:.2f}, {scale.observer_longitude:.2f})" 68 | f" {scale.time}", 69 | wrap=True, 70 | ) 71 | fg.set_tight_layout(True) 72 | 73 | return fg 74 | 75 | 76 | def ra_dec(scale, plottype: str = "singlecontour", img=None): 77 | """ 78 | plot right ascension and declination mapped to sky 79 | """ 80 | if "ra" not in scale: 81 | return None 82 | 83 | if plottype == "singlecontour": 84 | fg: typing.Any = Figure() 85 | ax: typing.Any = fg.gca() 86 | if img is not None: 87 | ax.imshow(img, origin="lower", cmap="gray") 88 | cs = ax.contour(scale["x"], scale["y"], scale["ra"]) 89 | ax.clabel(cs, inline=1, fmt="%0.1f") 90 | cs = ax.contour(scale["x"], scale["y"], scale["dec"]) 91 | ax.clabel(cs, inline=True, fmt="%0.1f") 92 | ax.set_xlabel("x-pixel") 93 | ax.set_ylabel("y-pixel") 94 | ax.set_title(f"{Path(scale.filename).name} Right Ascension / Declination") 95 | fg.set_tight_layout(True) 96 | return fg 97 | elif plottype == "image": 98 | fg = Figure(figsize=(12, 5)) 99 | ax = fg.subplots(1, 2, sharey=True) 100 | hri = ax[0].imshow(scale["ra"], origin="lower") 101 | hc = fg.colorbar(hri) 102 | hc.set_label("RA [deg]") 103 | elif plottype == "contour": 104 | fg = Figure(figsize=(12, 5)) 105 | ax = fg.subplots(1, 2, sharey=True) 106 | if img is not None: 107 | ax[0].imshow(img, origin="lower", cmap="gray") 108 | cs = ax[0].contour(scale["x"], scale["y"], scale["ra"]) 109 | ax[0].clabel(cs, inline=1, fmt="%0.1f") 110 | 111 | ax[0].set_xlabel("x-pixel") 112 | ax[0].set_ylabel("y-pixel") 113 | ax[0].set_title("Right Ascension ") 114 | # %% 115 | if plottype == "image": 116 | hdi = ax[1].imshow(scale["dec"], origin="lower") 117 | hc = fg.colorbar(hdi) 118 | hc.set_label("Dec [deg]") 119 | elif plottype == "contour": 120 | if img is not None: 121 | ax[1].imshow(img, origin="lower", cmap="gray") 122 | cs = ax[1].contour(scale["x"], scale["y"], scale["dec"]) 123 | ax[1].clabel(cs, inline=1, fmt="%0.1f") 124 | 125 | ax[1].set_xlabel("x-pixel") 126 | ax[1].set_title("Declination") 127 | fg.suptitle(Path(scale.filename).name) 128 | 129 | fg.set_tight_layout(True) 130 | 131 | return fg 132 | 133 | 134 | def image_stack(img, fn: Path, clim=None): 135 | """ 136 | plot image 137 | """ 138 | fn = Path(fn).expanduser() 139 | # %% plotting 140 | if img.ndim == 3 and img.shape[0] == 3: # it seems to be an RGB image 141 | cmap = None 142 | imnorm = None 143 | img = img.transpose([1, 2, 0]) # imshow() needs colors to be last axis 144 | else: 145 | cmap = "gray" 146 | imnorm = None 147 | # imnorm = LogNorm() 148 | 149 | fg = Figure() 150 | ax = fg.gca() 151 | if clim is None: 152 | hi = ax.imshow(img, origin="lower", interpolation="none", cmap=cmap, norm=imnorm) 153 | else: 154 | hi = ax.imshow( 155 | img, 156 | origin="lower", 157 | interpolation="none", 158 | cmap=cmap, 159 | vmin=clim[0], 160 | vmax=clim[1], 161 | norm=imnorm, 162 | ) 163 | ax.set_xlabel("x") 164 | ax.set_ylabel("y") 165 | ax.set_title(str(fn)) 166 | if cmap is not None: 167 | try: 168 | hc = fg.colorbar(hi) 169 | hc.set_label(f"Data numbers {img.dtype}") 170 | except ValueError as e: 171 | logging.warning(f"trouble making picture colorbar {e}") 172 | 173 | plotFN = fn.parent / (fn.stem + "_picture.png") 174 | print("writing", plotFN) 175 | fg.savefig(plotFN) 176 | 177 | 178 | def wcs_image(fn: Path, cmap, ax, alpha=1): 179 | """ 180 | Astrometry.net makes file ".new" with the image and the WCS SIP 2-D polynomial fit coefficients in the FITS header 181 | 182 | Warps the image to sky coordinates using the WCS. 183 | 184 | pcolormesh() is used as it handles arbitrary pixel shapes. 185 | Note that pcolormesh() cannot tolerate NaN in X or Y (NaN in C is OK). 186 | 187 | https://github.com/scivision/python-matlab-examples/blob/main/PlotPcolor/pcolormesh_NaN.py 188 | """ 189 | 190 | fn = Path(fn).expanduser().resolve(True) 191 | 192 | with fits.open(fn, mode="readonly", memmap=False) as f: 193 | img = f[0].data 194 | 195 | yPix, xPix = f[0].shape[-2:] 196 | x, y = np.meshgrid(range(xPix), range(yPix)) # pixel indices to find RA/dec of 197 | xy = np.column_stack((x.ravel(order="C"), y.ravel(order="C"))) 198 | 199 | radec = wcs.WCS(f[0].header).all_pix2world(xy, 0) 200 | 201 | ra = radec[:, 0].reshape((yPix, xPix), order="C") 202 | dec = radec[:, 1].reshape((yPix, xPix), order="C") 203 | 204 | ax.set_title(fn.name) 205 | ax.pcolormesh(ra, dec, img, alpha=alpha, cmap=cmap, norm=LogNorm()) 206 | ax.set_ylabel("Right Ascension [deg.]") 207 | ax.set_xlabel("Declination [deg.]") 208 | 209 | 210 | def xy_image(fn: Path, cmap, ax): 211 | """ 212 | Astrometry.net makes file ".new" with the image and the WCS SIP 2-D polynomial fit coefficients in the FITS header 213 | 214 | We use DECL as "x" and RA as "y". 215 | pcolormesh() is used as it handles arbitrary pixel shapes. 216 | Note that pcolormesh() cannot tolerate NaN in X or Y (NaN in C is OK). 217 | 218 | https://github.com/scivision/python-matlab-examples/blob/main/PlotPcolor/pcolormesh_NaN.py 219 | """ 220 | 221 | fn = Path(fn).expanduser().resolve(True) 222 | 223 | with fits.open(fn, mode="readonly", memmap=False) as f: 224 | img = f[0].data 225 | 226 | ax.set_title(fn.name) 227 | ax.pcolormesh(img, cmap=cmap, norm=LogNorm()) 228 | ax.set_ylabel("y - pixel") 229 | ax.set_xlabel("x - pixel") 230 | -------------------------------------------------------------------------------- /src/astrometry_azel/tests/apod4.wcs: -------------------------------------------------------------------------------- 1 | SIMPLE = T / Standard FITS file BITPIX = 8 / ASCII or bytes array NAXIS = 0 / Minimal header EXTEND = T / There may be FITS ext WCSAXES = 2 / no comment CTYPE1 = 'RA---TAN-SIP' / TAN (gnomic) projection + SIP distortions CTYPE2 = 'DEC--TAN-SIP' / TAN (gnomic) projection + SIP distortions EQUINOX = 2000.0 / Equatorial coordinates definition (yr) LONPOLE = 180.0 / no comment LATPOLE = 0.0 / no comment CRVAL1 = 177.445974041 / RA of reference point CRVAL2 = 53.3571476574 / DEC of reference point CRPIX1 = 349.240890503 / X reference pixel CRPIX2 = 117.089838982 / Y reference pixel CUNIT1 = 'deg ' / X pixel scale units CUNIT2 = 'deg ' / Y pixel scale units CD1_1 = 0.0300206031341 / Transformation matrix CD1_2 = 0.0362155530186 / no comment CD2_1 = -0.0363840390602 / no comment CD2_2 = 0.0298368829257 / no comment IMAGEW = 719 / Image width, in pixels. IMAGEH = 507 / Image height, in pixels. A_ORDER = 2 / Polynomial order, axis 1 A_0_0 = 0 / no comment A_0_1 = 0 / no comment A_0_2 = 1.08451787462E-06 / no comment A_1_0 = 0 / no comment A_1_1 = 9.09386201853E-05 / no comment A_2_0 = -8.25630261067E-06 / no comment B_ORDER = 2 / Polynomial order, axis 2 B_0_0 = 0 / no comment B_0_1 = 0 / no comment B_0_2 = 9.39597818628E-05 / no comment B_1_0 = 0 / no comment B_1_1 = -8.47014750625E-06 / no comment B_2_0 = -1.41476559717E-07 / no comment AP_ORDER= 2 / Inv polynomial order, axis 1 AP_0_0 = 0.0174593125837 / no comment AP_0_1 = -0.00017890048565 / no comment AP_0_2 = -8.1737343778E-07 / no comment AP_1_0 = 7.79383024161E-05 / no comment AP_1_1 = -8.65228071678E-05 / no comment AP_2_0 = 7.84838235334E-06 / no comment BP_ORDER= 2 / Inv polynomial order, axis 2 BP_0_0 = -0.0519600808439 / no comment BP_0_1 = -0.000254052671765 / no comment BP_0_2 = -8.70852125352E-05 / no comment BP_1_0 = -1.07079375165E-05 / no comment BP_1_1 = 7.65244449521E-06 / no comment BP_2_0 = 1.52641047839E-07 / no comment HISTORY Created by the Astrometry.net suite. HISTORY For more details, see http://astrometry.net. HISTORY Git URL https://github.com/dstndstn/astrometry.net HISTORY Git revision 0.89 HISTORY Git date Mon_Jan_17_07:47:26_2022_-0500 HISTORY This is a WCS header was created by Astrometry.net. DATE = '2023-06-08T17:55:56' / Date this file was created. COMMENT -- onefield solver parameters: -- COMMENT Index(0): /home/box/astrometry-data/index-4219.fits COMMENT Index(1): /home/box/astrometry-data/index-4218.fits COMMENT Index(2): /home/box/astrometry-data/index-4217.fits COMMENT Index(3): /home/box/astrometry-data/index-4216.fits COMMENT Index(4): /home/box/astrometry-data/index-4215.fits COMMENT Index(5): /home/box/astrometry-data/index-4214.fits COMMENT Index(6): /home/box/astrometry-data/index-4213.fits COMMENT Index(7): /home/box/astrometry-data/index-4212.fits COMMENT Field name: src/astrometry_azel/tests/apod4.axy COMMENT Field scale lower: 150.209 arcsec/pixel COMMENT Field scale upper: 901.252 arcsec/pixel COMMENT X col name: X COMMENT Y col name: Y COMMENT Start obj: 10 COMMENT End obj: 20 COMMENT Solved_in: (null) COMMENT Solved_out: src/astrometry_azel/tests/apod4.solved COMMENT Parity: 2 COMMENT Codetol: 0.01 COMMENT Verify pixels: 1 pix COMMENT Maxquads: 0 COMMENT Maxmatches: 0 COMMENT Cpu limit: 60.000000 s COMMENT Time limit: 0 s COMMENT Total time limit: 0 s COMMENT Total CPU limit: 60.000000 s COMMENT Tweak: yes COMMENT Tweak AB order: 2 COMMENT Tweak ABP order: 2 COMMENT -- COMMENT -- properties of the matching quad: -- COMMENT index id: 4218 COMMENT index healpix: -1 COMMENT index hpnside: 0 COMMENT log odds: 148.866 COMMENT odds: 4.48423e+64 COMMENT quadno: 1959 COMMENT stars: 861,439,856,0 COMMENT field: 17,8,9,0 COMMENT code error: 0.0060504 COMMENT nmatch: 31 COMMENT nconflict: 2 COMMENT nfield: 1467 COMMENT nindex: 35 COMMENT scale: 169.368 arcsec/pix COMMENT parity: 1 COMMENT quads tried: 360 COMMENT quads matched: 844 COMMENT quads verified: 599 COMMENT objs tried: 18 COMMENT cpu time: 0.051815 COMMENT -- END --------------------------------------------------------------------------------