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. """ # no reference image? if self._ref_image is None: log.info("Initialising auto-guiding with new image...") self._ref_image = self._process(image) self._init_pid() self.offset = (0, 0) return image # process it log.info("Perform auto-guiding on new image...") sum_x, sum_y = self._process(image) # find peaks dx = self._correlate(sum_x, self._ref_image[0]) dy = self._correlate(sum_y, self._ref_image[1]) if dx is None or dy is None: log.error("Could not correlate peaks.") return image # set it image.set_meta(PixelOffsets(dx, dy)) return image
def _set_exp_time(self, image: Image, exp_time: float) -> None: """Internal setter for exposure time.""" # min exp time if self._min_exp_time is not None and exp_time < self._min_exp_time: exp_time = self._min_exp_time # max exp time if self._max_exp_time is not None and exp_time > self._max_exp_time: exp_time = self._max_exp_time # set it image.set_meta(ExpTime(exp_time))
async def write_image(self, filename: str, image: Image, *args: Any, **kwargs: Any) -> None: """Convenience function for writing an Image to a FITS file. Args: filename: Name of file to write. image: Image to write. """ # open file async with self.open_file(filename, "wb") as f: with io.BytesIO() as bio: image.writeto(bio, *args, **kwargs) await f.write(bio.getvalue())
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
def _get_radec_center_target( self, image: Image, location: EarthLocation) -> Tuple[SkyCoord, SkyCoord]: """Return RA/Dec of central pixel and of central pixel plus offsets. Args: image: Image to take header and offsets from. location: Observer location. Returns: Tuple of RA/Dec coordinates of center and of centre+offsets. """ # get offsets offsets = image.get_meta(PixelOffsets) log.info("Found pixel shift of dx=%.2f, dy=%.2f.", offsets.dx, offsets.dy) # get reference pixel and date obs cx, cy = image.header["DET-CPX1"], image.header["DET-CPX2"] # get WCS and RA/DEC for pixel and pixel + dx/dy w = WCS(image.header) center = w.pixel_to_world(cx, cy) target = w.pixel_to_world(cx + offsets.dx, cy + offsets.dy) return center, target
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
def get_image(self, exp_time: float, open_shutter: bool) -> Image: """Simulate an image. Args: exp_time: Exposure time in seconds. open_shutter: Whether the shutter is opened. Returns: numpy array with image. """ # get now now = Time.now() # simulate or what? if self.images: # take image from list filename = self.images.pop(0) data = fits.getdata(filename) self.images.append(filename) else: # simulate data = self._simulate_image(exp_time, open_shutter) # create header hdr = self._create_header(exp_time, open_shutter, now, data) # return it return Image(data, header=hdr)
async def download_frames(self, infos: List[FrameInfo]) -> List[Image]: # loop infos images = [] for info in infos: # make sure it's the correct FrameInfo if not isinstance(info, PyobsArchiveFrameInfo): log.warning("Incorrect type for frame info.") continue # download url = urllib.parse.urljoin(self._url, info.url) async with aiohttp.ClientSession() as session: async with session.get(url, headers=self._headers, timeout=60) as response: if response.status != 200: log.exception("Error downloading file %s.", info.filename) # create image try: image = Image.from_bytes(await response.read()) images.append(image) except OSError: log.exception("Error downloading file %s.", info.filename) # return all return images
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 read_image(self, filename: str) -> Image: """Convenience function that wraps around open_file() to read an Image. Args: filename: Name of file to download. Returns: An image object """ async with self.open_file(filename, "rb") as f: data = await f.read() return Image.from_bytes(data)
def _get_image(self, exp_time: float, open_shutter: bool) -> Image: """Actually get (i.e. simulate) the image.""" # random image or pre-defined? if self._sim_images: filename = self._sim_images.pop(0) self._sim_images.append(filename) return Image.from_file(filename) else: image = self._camera.get_image(exp_time, open_shutter) return image
def _combine_calib_images( images: List[Image], bias: Optional[Image] = None, normalize: bool = False, method: str = "average" ) -> Image: """Combine a list of given images. Args: images: List of images to combine. bias: If given, subtract from images before combining them. normalize: If True, images are normalized to median of 1 before and after combining them. method: Method for combining images. """ import ccdproc # get CCDData objects data = [image.to_ccddata() for image in images] # subtract bias? if bias is not None: bias_data = bias.to_ccddata() data = [ccdproc.subtract_bias(d, bias_data) for d in data] # normalize? if normalize: data = [d.divide(np.median(d.data), handle_meta="first_found") for d in data] # combine image combined = ccdproc.combine( data, method=method, sigma_clip=True, sigma_clip_low_thresh=5, sigma_clip_high_thresh=5, mem_limit=350e6, unit="adu", combine_uncertainty_function=np.ma.std, ) # normalize? if normalize: combined = combined.divide(np.median(combined.data), handle_meta="first_found") # to Image and copy header image = Image.from_ccddata(combined) # add history for i, src in enumerate(images, 1): basename = src.header["FNAME"].replace(".fits.fz", "").replace(".fits", "") image.header["L1AVG%03d" % i] = (basename, "Image used for average") image.header["RLEVEL"] = (1, "Reduction level") # finished return image
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: """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. """ # get values from header target = [image.header[x] for x in self.target] center = [image.header[x] for x in self.center] # calculate offset image.meta["offsets"] = np.subtract(target, center) return image
async def __call__(self, image: Image) -> Image: """Broadcast image. Args: image: Image to broadcast. Returns: Original image. """ # format filename filename = image.format_filename(self._formatter) # upload await self.vfs.write_image(filename, image) # broadcast await self.comm.send_event(NewImageEvent(filename, image_type=ImageType(image.header["IMAGETYP"]))) # finished return image
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: """Calibrate an image. Args: image: Image to calibrate. Returns: Calibrated image. """ import ccdproc # get calibration masters try: bias = await self._find_master(image, ImageType.BIAS) dark = await self._find_master(image, ImageType.DARK) flat = await self._find_master(image, ImageType.SKYFLAT) except ValueError as e: log.error("Could not find calibration frames: " + str(e)) return image # calibrate image c = await asyncio.get_running_loop().run_in_executor( None, partial( ccdproc.ccd_process, image.to_ccddata(), oscan=image.header["BIASSEC"] if "BIASSEC" in image.header else None, trim=image.header["TRIMSEC"] if "TRIMSEC" in image.header else None, error=True, master_bias=bias.to_ccddata() if bias is not None else None, dark_frame=dark.to_ccddata() if dark is not None else None, master_flat=flat.to_ccddata() if flat is not None else None, bad_pixel_mask=None, gain=image.header["DET-GAIN"] * u.electron / u.adu, readnoise=image.header["DET-RON"] * u.electron, dark_exposure=dark.header["EXPTIME"] * u.second if dark is not None else None, data_exposure=image.header["EXPTIME"] * u.second, dark_scale=True, gain_corrected=False, ), ) # to image calibrated = Image.from_ccddata(c) calibrated.header["BUNIT"] = ("electron", "Unit of pixel values") # set raw filename if "ORIGNAME" in image.header: calibrated.header["L1RAW"] = image.header["ORIGNAME"].replace( ".fits", "") # add calibration frames if bias is not None: calibrated.header["L1BIAS"] = ( bias.header["FNAME"].replace(".fits.fz", "").replace(".fits", ""), "Name of BIAS frame", ) if dark is not None: calibrated.header["L1DARK"] = ( dark.header["FNAME"].replace(".fits.fz", "").replace(".fits", ""), "Name of DARK frame", ) if flat is not None: calibrated.header["L1FLAT"] = ( flat.header["FNAME"].replace(".fits.fz", "").replace(".fits", ""), "Name of FLAT frame", ) # set RLEVEL calibrated.header["RLEVEL"] = (1, "Reduction level") # finished return calibrated
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 _expose(self, exposure_time: float, open_shutter: bool, abort_event: asyncio.Event) -> Image: """Actually do the exposure, should be implemented by derived classes. Args: exposure_time: The requested exposure time in seconds. open_shutter: Whether or not to open the shutter. abort_event: Event that gets triggered when exposure should be aborted. Returns: The actual image. Raises: GrabImageError: If exposure was not successful. """ from .flidriver import FliTemperature # check driver if self._driver is None: raise ValueError("No camera driver.") # set binning log.info("Set binning to %dx%d.", self._binning[0], self._binning[1]) self._driver.set_binning(*self._binning) # set window, divide width/height by binning, from libfli: # "Note that the given lower-right coordinate must take into account the horizontal and # vertical bin factor settings, but the upper-left coordinate is absolute." width = int(math.floor(self._window[2]) / self._binning[0]) height = int(math.floor(self._window[3]) / self._binning[1]) log.info( "Set window to %dx%d (binned %dx%d) at %d,%d.", self._window[2], self._window[3], width, height, self._window[0], self._window[1], ) self._driver.set_window(self._window[0], self._window[1], width, height) # set some stuff self._driver.init_exposure(open_shutter) self._driver.set_exposure_time(int(exposure_time * 1000.0)) # get date obs log.info( "Starting exposure with %s shutter for %.2f seconds...", "open" if open_shutter else "closed", exposure_time ) date_obs = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%f") # do exposure self._driver.start_exposure() # wait for exposure to finish while True: # aborted? if abort_event.is_set(): await self._change_exposure_status(ExposureStatus.IDLE) raise InterruptedError("Aborted exposure.") # is exposure finished? if self._driver.is_exposing(): break else: # sleep a little time.sleep(0.2) # readout log.info("Exposure finished, reading out...") await self._change_exposure_status(ExposureStatus.READOUT) width = int(math.floor(self._window[2] / self._binning[0])) height = int(math.floor(self._window[3] / self._binning[1])) img = np.zeros((height, width), dtype=np.uint16) for row in range(height): img[row, :] = self._driver.grab_row(width) # create FITS image and set header image = Image(img) image.header["DATE-OBS"] = (date_obs, "Date and time of start of exposure") image.header["EXPTIME"] = (exposure_time, "Exposure time [s]") image.header["DET-TEMP"] = (self._driver.get_temp(FliTemperature.CCD), "CCD temperature [C]") image.header["DET-COOL"] = (self._driver.get_cooler_power(), "Cooler power [percent]") image.header["DET-TSET"] = (self._temp_setpoint, "Cooler setpoint [C]") # instrument and detector image.header["INSTRUME"] = (self._driver.name, "Name of instrument") # binning image.header["XBINNING"] = image.header["DET-BIN1"] = (self._binning[0], "Binning factor used on X axis") image.header["YBINNING"] = image.header["DET-BIN2"] = (self._binning[1], "Binning factor used on Y axis") # window image.header["XORGSUBF"] = (self._window[0], "Subframe origin on X axis") image.header["YORGSUBF"] = (self._window[1], "Subframe origin on Y axis") # statistics image.header["DATAMIN"] = (float(np.min(img)), "Minimum data value") image.header["DATAMAX"] = (float(np.max(img)), "Maximum data value") image.header["DATAMEAN"] = (float(np.mean(img)), "Mean data value") # biassec/trimsec full = self._driver.get_visible_frame() self.set_biassec_trimsec(image.header, *full) # return FITS image log.info("Readout finished.") return image
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
async def _expose(self, exposure_time: float, open_shutter: bool, abort_event: asyncio.Event) -> Image: """Actually do the exposure, should be implemented by derived classes. Args: exposure_time: The requested exposure time in ms. open_shutter: Whether or not to open the shutter. abort_event: Event that gets triggered when exposure should be aborted. Returns: The actual image. Raises: pyobs.utils.exceptions.GrabImageError: If exposure was not successful. """ async with self._lock_active: # binning binning = self._binning # set window, CSBIGCam expects left/top also in binned coordinates, so divide by binning left = int(math.floor(self._window[0]) / binning[0]) top = int(math.floor(self._window[1]) / binning[1]) width = int(math.floor(self._window[2]) / binning[0]) height = int(math.floor(self._window[3]) / binning[1]) log.info( "Set window to %dx%d (binned %dx%d) at %d,%d.", self._window[2], self._window[3], width, height, left, top, ) window = (left, top, width, height) # get date obs log.info("Starting exposure with for %.2f seconds...", exposure_time) date_obs = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%f") # init image self._img.image_can_close = False # set exposure time, window and binning self._cam.exposure_time = exposure_time self._cam.window = window self._cam.binning = binning # start exposure self._cam.start_exposure(self._img, open_shutter) # wait for it while not self._cam.has_exposure_finished(): # was aborted? if abort_event.is_set(): raise InterruptedError("Exposure aborted.") await asyncio.sleep(0.01) # finish exposure self._cam.end_exposure() # wait for readout log.info("Exposure finished, reading out...") await self._change_exposure_status(ExposureStatus.READOUT) # start readout (can raise ValueError) loop = asyncio.get_running_loop() await loop.run_in_executor(None, self._cam.readout, self._img, open_shutter) # finalize image self._img.image_can_close = True # download data data = self._img.data # temp & cooling _, temp, setpoint, _ = self._cam.get_cooling() # create FITS image and set header img = Image(data) img.header["DATE-OBS"] = (date_obs, "Date and time of start of exposure") img.header["EXPTIME"] = (exposure_time, "Exposure time [s]") img.header["DET-TEMP"] = (temp, "CCD temperature [C]") img.header["DET-TSET"] = (setpoint, "Cooler setpoint [C]") # binning img.header["XBINNING"] = img.header["DET-BIN1"] = (self._binning[0], "Binning factor used on X axis") img.header["YBINNING"] = img.header["DET-BIN2"] = (self._binning[1], "Binning factor used on Y axis") # window img.header["XORGSUBF"] = (self._window[0], "Subframe origin on X axis") img.header["YORGSUBF"] = (self._window[1], "Subframe origin on Y axis") # statistics img.header["DATAMIN"] = (float(np.min(data)), "Minimum data value") img.header["DATAMAX"] = (float(np.max(data)), "Maximum data value") img.header["DATAMEAN"] = (float(np.mean(data)), "Mean data value") # biassec/trimsec self.set_biassec_trimsec(img.header, *self._full_frame) # return FITS image log.info("Readout finished.") 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 _expose(self, exposure_time: float, open_shutter: bool, abort_event: threading.Event) -> Image: """Actually do the exposure, should be implemented by derived classes. Args: exposure_time: The requested exposure time in s. open_shutter: Whether or not to open the shutter. abort_event: Event that gets triggered when exposure should be aborted. Returns: The actual image. """ # no camera? if self._camera is None: raise ValueError('No camera initialised.') # get image format image_format = FORMATS[self._image_format] # set window, divide width/height by binning width = int(math.floor(self._window[2]) / self._binning) height = int(math.floor(self._window[3]) / self._binning) log.info("Set window to %dx%d (binned %dx%d with %dx%d) at %d,%d.", self._window[2], self._window[3], width, height, self._binning, self._binning, self._window[0], self._window[1]) self._camera.set_roi(int(self._window[0]), int(self._window[1]), width, height, self._binning, image_format) # set status and exposure time in ms self._change_exposure_status(ExposureStatus.EXPOSING) self._camera.set_control_value(asi.ASI_EXPOSURE, int(exposure_time * 1e6)) # get date obs log.info('Starting exposure with %s shutter for %.2f seconds...', 'open' if open_shutter else 'closed', exposure_time) date_obs = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%f") # do exposure self._camera.start_exposure() self.closing.wait(0.01) # wait for image while self._camera.get_exposure_status() == asi.ASI_EXP_WORKING: # aborted? if abort_event.is_set(): self._change_exposure_status(ExposureStatus.IDLE) raise ValueError('Aborted exposure.') # sleep a little abort_event.wait(0.01) # success? status = self._camera.get_exposure_status() if status != asi.ASI_EXP_SUCCESS: raise ValueError('Could not capture image: %s' % status) # get data log.info('Exposure finished, reading out...') self._change_exposure_status(ExposureStatus.READOUT) buffer = self._camera.get_data_after_exposure() whbi = self._camera.get_roi_format() # decide on image format shape = [whbi[1], whbi[0]] if image_format == asi.ASI_IMG_RAW8: data = np.frombuffer(buffer, dtype=np.uint8) elif image_format == asi.ASI_IMG_RAW16: data = np.frombuffer(buffer, dtype=np.uint16) elif image_format == asi.ASI_IMG_RGB24: shape.append(3) data = np.frombuffer(buffer, dtype=np.uint8) else: raise ValueError('Unknown image format.') # reshape data = data.reshape(shape) # special treatment for RGB images if image_format == asi.ASI_IMG_RGB24: # convert BGR to RGB data = data[:, :, ::-1] # now we need to separate the R, G, and B images # this is easiest done by shifting the RGB axis from last to first position # i.e. we go from RGBRGBRGBRGBRGB to RRRRRGGGGGBBBBB data = np.moveaxis(data, 2, 0) # create FITS image and set header image = Image(data) image.header['DATE-OBS'] = (date_obs, 'Date and time of start of exposure') image.header['EXPTIME'] = (exposure_time, 'Exposure time [s]') # instrument and detector image.header['INSTRUME'] = (self._camera_name, 'Name of instrument') # binning image.header['XBINNING'] = image.header['DET-BIN1'] = ( self._binning, 'Binning factor used on X axis') image.header['YBINNING'] = image.header['DET-BIN2'] = ( self._binning, 'Binning factor used on Y axis') # window image.header['XORGSUBF'] = (self._window[0], 'Subframe origin on X axis') image.header['YORGSUBF'] = (self._window[1], 'Subframe origin on Y axis') # statistics image.header['DATAMIN'] = (float(np.min(data)), 'Minimum data value') image.header['DATAMAX'] = (float(np.max(data)), 'Maximum data value') image.header['DATAMEAN'] = (float(np.mean(data)), 'Mean data value') # pixels image.header['DET-PIXL'] = (self._camera_info['PixelSize'] / 1000., 'Size of detector pixels (square) [mm]') image.header['DET-GAIN'] = (self._camera_info['ElecPerADU'], 'Detector gain [e-/ADU]') # Bayer pattern? if image_format in [asi.ASI_IMG_RAW8, asi.ASI_IMG_RAW16]: image.header['BAYERPAT'] = image.header['COLORTYP'] = ( 'GBRG', 'Bayer pattern for colors') # biassec/trimsec self.set_biassec_trimsec(image.header, *self._window) # return FITS image log.info('Readout finished.') self._change_exposure_status(ExposureStatus.IDLE) return image