async def __call__(self, image: Image) -> Image: """Processes an image and sets x/y pixel offset to reference in offset attribute. Args: image: Image to process. Returns: Original image. Raises: ValueError: If offset could not be found. """ # copy image and get WCS # we make our life a little easier by only using the new WCS from astrometry img = image.copy() wcs = WCS(img.header) # get x/y coordinates from CRVAL1/2, i.e. from center with good WCS center = SkyCoord(img.header["CRVAL1"] * u.deg, img.header["CRVAL2"] * u.deg, frame="icrs") x_center, y_center = wcs.world_to_pixel(center) # get x/y coordinates from TEL-RA/-DEC, i.e. from where the telescope thought it's pointing tel = SkyCoord(img.header["TEL-RA"] * u.deg, img.header["TEL-DEC"] * u.deg, frame="icrs") x_tel, y_tel = wcs.world_to_pixel(tel) # calculate offsets as difference between both img.set_meta(PixelOffsets(x_tel - x_center, y_tel - y_center)) img.set_meta(OnSkyDistance(center.separation(tel))) return img
async def __call__(self, image: Image) -> Image: """Remove background from image. Args: image: Image to remove background from. Returns: Image without background. """ from photutils.background import Background2D, MedianBackground # init objects sigma_clip = SigmaClip(sigma=self.sigma) bkg_estimator = MedianBackground() # calculate background bkg = Background2D(image.data, self.box_size, filter_size=self.filter_size, sigma_clip=sigma_clip, bkg_estimator=bkg_estimator) # copy image and remove background img = image.copy() img.data = img.data - bkg.background return img
async def __call__(self, image: Image) -> Image: """Find stars in given image and append catalog. Args: image: Image to find stars in. Returns: Image with attached catalog. """ from astropy.stats import SigmaClip, sigma_clipped_stats from photutils import Background2D, MedianBackground, DAOStarFinder # get data if image.data is None: log.warning("No data found in image.") return image data = image.data.astype(float).copy() # estimate background sigma_clip = SigmaClip(sigma=self.bkg_sigma) bkg_estimator = MedianBackground() bkg = Background2D( data, self.bkg_box_size, filter_size=self.bkg_filter_size, sigma_clip=sigma_clip, bkg_estimator=bkg_estimator, mask=image.mask, ) data -= bkg.background # do statistics mean, median, std = sigma_clipped_stats(data, sigma=3.0) # find stars daofind = DAOStarFinder(fwhm=self.fwhm, threshold=self.threshold * std) loop = asyncio.get_running_loop() sources = await loop.run_in_executor(None, daofind, data - median) # rename columns sources.rename_column("xcentroid", "x") sources.rename_column("ycentroid", "y") # match fits conventions sources["x"] += 1 sources["y"] += 1 # pick columns for catalog cat = sources["x", "y", "flux", "peak"] # copy image, set catalog and return it img = image.copy() img.catalog = cat return img
async def __call__(self, image: Image) -> Image: """Add filename to image. Args: image: Image to add filename to. Returns: Image with filename in FNAME. """ # copy image and set filename img = image.copy() img.format_filename(self._formatter) return img
async def calibrate(self, image: Image) -> Image: """Calibrate a single science frame. Args: image: Image to calibrate. Returns: Calibrated image. """ # copy image calibrated = image.copy() # run pipeline return await self.run_pipeline(calibrated)
async def __call__(self, image: Image) -> Image: """Bin an image. Args: image: Image to bin. Returns: Binned image. """ # copy image img = image.copy() if img.data is None: log.warning("No data found in image.") return image # calculate new shape, in which all binned pixels are in a higher dimension shape = (img.data.shape[0] // self.binning, self.binning, img.data.shape[1] // self.binning, self.binning) # reshape and average img.data = img.data.reshape(shape).mean(-1).mean(1) if img.data is None: log.warning("No data found in image after reshaping.") return image # set NAXIS1/2 img.header["NAXIS2"], img.header["NAXIS1"] = img.data.shape # divide some header entries by binning for key in ["CRPIX1", "CRPIX2"]: if key in img.header: img.header[key] /= self.binning # multiply some header entries with binning for key in [ "DET-BIN1", "DET-BIN2", "XBINNING", "YBINNING", "CDELT1", "CDELT2" ]: if key in img.header: img.header[key] *= self.binning # return result return img
async def __call__(self, image: Image) -> Image: """Add mask to image. Args: image: Image to add mask to. Returns: Image with mask """ # copy image img = image.copy() # add mask instrument = image.header["INSTRUME"] binning = "%dx%s" % (image.header["XBINNING"], image.header["YBINNING"]) if binning in self._masks: img.mask = self._masks[instrument][binning].copy() else: log.warning("No mask found for binning of frame.") # finished return img
async def __call__(self, image: Image) -> Image: """Do aperture photometry on given image. Args: image: Image to do aperture photometry on. Returns: Image with attached catalog. """ loop = asyncio.get_running_loop() # no pixel scale given? if image.pixel_scale is None: log.warning("No pixel scale provided by image.") return image # fetch catalog if image.catalog is None: log.warning("No catalog in image.") return image sources = image.catalog.copy() # get positions positions = [(x - 1, y - 1) for x, y in sources.iterrows("x", "y")] # perform aperture photometry for diameters of 1" to 8" for diameter in [1, 2, 3, 4, 5, 6, 7, 8]: # extraction radius in pixels radius = diameter / 2.0 / image.pixel_scale if radius < 1: continue # defines apertures aperture = CircularAperture(positions, r=radius) annulus_aperture = CircularAnnulus(positions, r_in=2 * radius, r_out=3 * radius) annulus_masks = annulus_aperture.to_mask(method="center") # loop annuli bkg_median = [] for m in annulus_masks: annulus_data = m.multiply(image.data) annulus_data_1d = annulus_data[m.data > 0] _, median_sigclip, _ = sigma_clipped_stats(annulus_data_1d) bkg_median.append(median_sigclip) # do photometry phot = await loop.run_in_executor( None, partial(aperture_photometry, image.data, aperture, mask=image.mask, error=image.uncertainty)) # calc flux bkg_median_np = np.array(bkg_median) aper_bkg = bkg_median_np * aperture.area sources["fluxaper%d" % diameter] = phot["aperture_sum"] - aper_bkg if "aperture_sum_err" in phot.columns: sources["fluxerr%d" % diameter] = phot["aperture_sum_err"] sources["bkgaper%d" % diameter] = bkg_median_np # copy image, set catalog and return it img = image.copy() img.catalog = sources return img
async def __call__(self, image: Image) -> Image: """Find astrometric solution on given image. Writes WCSERR=1 into FITS header on failure. Args: image: Image to analyse. """ # copy image img = image.copy() # get catalog if img.catalog is None: log.warning("No catalog found in image.") return image cat = img.catalog[["x", "y", "flux"]].to_pandas().dropna() # nothing? if cat is None or len(cat) < 3: log.warning("Not enough sources for astrometry.") img.header["WCSERR"] = 1 return img # sort it and take N brightest sources cat = cat.sort_values("flux", ascending=False) cat = cat[: self.source_count] # no CDELT1? if "CDELT1" not in img.header: log.warning("No CDELT1 found in header.") img.header["WCSERR"] = 1 return img # build request data scale = abs(img.header["CDELT1"]) * 3600 data = { "ra": img.header["TEL-RA"], "dec": img.header["TEL-DEC"], "scale_low": scale * 0.9, "scale_high": scale * 1.1, "radius": self.radius, "nx": img.header["NAXIS1"], "ny": img.header["NAXIS2"], "x": cat["x"].tolist(), "y": cat["y"].tolist(), "flux": cat["flux"].tolist(), } # log it ra_dec = SkyCoord(ra=data["ra"] * u.deg, dec=data["dec"] * u.deg, frame="icrs") cx, cy = img.header["CRPIX1"], img.header["CRPIX2"] log.info( "Found original RA=%s (%.4f), Dec=%s (%.4f) at pixel %.2f,%.2f.", ra_dec.ra.to_string(sep=":", unit=u.hour, pad=True), data["ra"], ra_dec.dec.to_string(sep=":", unit=u.deg, pad=True), data["dec"], cx, cy, ) # send it async with aiohttp.ClientSession() as session: async with session.post(self.url, json=data, timeout=10) as response: status_code = response.status json = await response.json() # success? if status_code != 200 or "error" in json: # set error img.header["WCSERR"] = 1 if "error" in json: # "Could not find WCS file." is just an info, which means that WCS was not successful if json["error"] == "Could not find WCS file.": log.info("Could not determine WCS.") else: log.warning("Received error from astrometry service: %s", json["error"]) else: log.error("Could not connect to astrometry service.") return img else: # copy keywords hdr = json header_keywords_to_update = [ "CTYPE1", "CTYPE2", "CRPIX1", "CRPIX2", "CRVAL1", "CRVAL2", "CD1_1", "CD1_2", "CD2_1", "CD2_2", ] for keyword in header_keywords_to_update: img.header[keyword] = hdr[keyword] # astrometry.net gives a CD matrix, so we have to delete the PC matrix and the CDELT* parameters for keyword in ["PC1_1", "PC1_2", "PC2_1", "PC2_2", "CDELT1", "CDELT2"]: del img.header[keyword] # calculate world coordinates for all sources in catalog image_wcs = WCS(img.header) ras, decs = image_wcs.all_pix2world(img.catalog["x"], img.catalog["y"], 1) # set them img.catalog["ra"] = ras img.catalog["dec"] = decs # RA/Dec at center pos final_ra, final_dec = image_wcs.all_pix2world(cx, cy, 0) ra_dec = SkyCoord(ra=final_ra * u.deg, dec=final_dec * u.deg, frame="icrs") # log it log.info( "Found final RA=%s (%.4f), Dec=%s (%.4f) at pixel %.2f,%.2f.", ra_dec.ra.to_string(sep=":", unit=u.hour, pad=True), data["ra"], ra_dec.dec.to_string(sep=":", unit=u.deg, pad=True), data["dec"], cx, cy, ) # success img.header["WCSERR"] = 0 # finished return img
async def __call__(self, image: Image) -> Image: """Find stars in given image and append catalog. Args: image: Image to find stars in. Returns: Image with attached catalog. """ import sep loop = asyncio.get_running_loop() # got data? if image.data is None: log.warning("No data found in image.") return image # no mask? mask = image.mask if image.mask is not None else np.zeros( image.data.shape, dtype=bool) # remove background data, bkg = SepSourceDetection.remove_background(image.data, mask) # extract sources sources = await loop.run_in_executor( None, partial( sep.extract, data, self.threshold, err=bkg.globalrms, minarea=self.minarea, deblend_nthresh=self.deblend_nthresh, deblend_cont=self.deblend_cont, clean=self.clean, clean_param=self.clean_param, mask=image.mask, ), ) # convert to astropy table sources = pd.DataFrame(sources) # only keep sources with detection flag < 8 sources = sources[sources["flag"] < 8] x, y = sources["x"], sources["y"] # Calculate the ellipticity sources["ellipticity"] = 1.0 - (sources["b"] / sources["a"]) # calculate the FWHMs of the stars fwhm = 2.0 * (np.log(2) * (sources["a"]**2.0 + sources["b"]**2.0))**0.5 sources["fwhm"] = fwhm # clip theta to [-pi/2,pi/2] sources["theta"] = sources["theta"].clip(lower=np.pi / 2, upper=np.pi / 2) # Kron radius kronrad, krflag = sep.kron_radius(data, x, y, sources["a"], sources["b"], sources["theta"], 6.0) sources["flag"] |= krflag sources["kronrad"] = kronrad # equivalent of FLUX_AUTO gain = image.header["DET-GAIN"] if "DET-GAIN" in image.header else None flux, fluxerr, flag = await loop.run_in_executor( None, partial( sep.sum_ellipse, data, x, y, sources["a"], sources["b"], sources["theta"], 2.5 * kronrad, subpix=5, mask=image.mask, gain=gain, ), ) sources["flag"] |= flag sources["flux"] = flux # radii at 0.25, 0.5, and 0.75 flux flux_radii, flag = sep.flux_radius(data, x, y, 6.0 * sources["a"], [0.25, 0.5, 0.75], normflux=sources["flux"], subpix=5) sources["flag"] |= flag sources["fluxrad25"] = flux_radii[:, 0] sources["fluxrad50"] = flux_radii[:, 1] sources["fluxrad75"] = flux_radii[:, 2] # xwin/ywin sig = 2.0 / 2.35 * sources["fluxrad50"] xwin, ywin, flag = sep.winpos(data, x, y, sig) sources["flag"] |= flag sources["xwin"] = xwin sources["ywin"] = ywin # theta in degrees sources["theta"] = np.degrees(sources["theta"]) # only keep sources with detection flag < 8 sources = sources[sources["flag"] < 8] # match fits conventions sources["x"] += 1 sources["y"] += 1 # pick columns for catalog cat = sources[[ "x", "y", "peak", "flux", "fwhm", "a", "b", "theta", "ellipticity", "tnpix", "kronrad", "fluxrad25", "fluxrad50", "fluxrad75", "xwin", "ywin", ]] # copy image, set catalog and return it img = image.copy() img.catalog = Table.from_pandas(cat) return img
def _photometry(image: Image) -> Image: import sep from pyobs.images.processors.detection import SepSourceDetection # check data if image.data is None: log.warning("No data found in image.") return image if image.catalog is None: log.warning("No catalog found in image.") return image # no mask? mask = image.mask if image.mask is not None else np.ones(image.data.shape, dtype=bool) # remove background data, bkg = SepSourceDetection.remove_background(image.data, mask) # fetch catalog sources = image.catalog.copy() # match SEP conventions x, y = sources["x"] - 1, sources["y"] - 1 # get gain gain = image.header["DET-GAIN"] if "DET-GAIN" in image.header else None # perform aperture photometry for diameters of 1" to 8" for diameter in [1, 2, 3, 4, 5, 6, 7, 8]: if image.pixel_scale is not None: flux, fluxerr, flag = sep.sum_circle( data, x, y, diameter / 2.0 / image.pixel_scale, mask=image.mask, err=image.uncertainty, gain=gain ) sources["fluxaper{0}".format(diameter)] = flux sources["fluxerr{0}".format(diameter)] = fluxerr else: sources["fluxaper{0}".format(diameter)] = 0 sources["fluxerr{0}".format(diameter)] = 0 # average background at each source # since SEP sums up whole pixels, we need to do the same on an image of ones for the background_area bkgflux, fluxerr, flag = sep.sum_ellipse( bkg.back(), x, y, sources["a"], sources["b"], np.pi / 2.0, 2.5 * sources["kronrad"], subpix=1 ) background_area, _, _ = sep.sum_ellipse( np.ones(shape=bkg.back().shape), x, y, sources["a"], sources["b"], np.pi / 2.0, 2.5 * sources["kronrad"], subpix=1, ) sources["background"] = bkgflux sources["background"][background_area > 0] /= background_area[background_area > 0] # pick columns for catalog new_columns = [ "fluxaper1", "fluxerr1", "fluxaper2", "fluxerr2", "fluxaper3", "fluxerr3", "fluxaper4", "fluxerr4", "fluxaper5", "fluxerr5", "fluxaper6", "fluxerr6", "fluxaper7", "fluxerr7", "fluxaper8", "fluxerr8", "background", ] cat = sources[image.catalog.colnames + new_columns] # copy image, set catalog and return it img = image.copy() img.catalog = cat return img