def test_vollath_f4(data_dir): data = fits.getdata(os.path.join(data_dir, 'unsolved.fits')) data = mask_saturated(data) assert focus_utils.vollath_F4(data) == pytest.approx(14667.207897717599) assert focus_utils.vollath_F4( data, axis='Y') == pytest.approx(14380.343807477504) assert focus_utils.vollath_F4( data, axis='X') == pytest.approx(14954.071987957694) with pytest.raises(ValueError): focus_utils.vollath_F4(data, axis='Z')
def test_focus_metric_bad_string(data_dir): data = fits.getdata(os.path.join(data_dir, 'unsolved.fits')) data = mask_saturated(data) with pytest.raises(KeyError): focus_utils.focus_metric(data, merit_function='NOTAMERITFUNCTION')
def _autofocus(self, seconds, focus_range, focus_step, cutout_size, keep_files, take_dark, merit_function, merit_function_kwargs, mask_dilations, make_plots, coarse, focus_event, *args, **kwargs): """Private helper method for calling autofocus in a Thread. See public `autofocus` for information about the parameters. """ focus_type = 'fine' if coarse: focus_type = 'coarse' initial_focus = self.position self.logger.debug( f"Beginning {focus_type} autofocus of {self._camera} - " f"initial position: {initial_focus}") # Set up paths for temporary focus files, and plots if requested. image_dir = self.get_config('directories.images') start_time = current_time(flatten=True) file_path_root = os.path.join(image_dir, 'focus', self._camera.uid, start_time) self._autofocus_error = None dark_cutout = None if take_dark: dark_path = os.path.join(file_path_root, f'dark.{self._camera.file_extension}') self.logger.debug( f'Taking dark frame {dark_path} on camera {self._camera}') try: dark_cutout = self._camera.get_cutout(seconds, dark_path, cutout_size, keep_file=True, dark=True) # Mask 'saturated' with a low threshold to remove hot pixels dark_cutout = mask_saturated(dark_cutout, threshold=0.3, bit_depth=self.camera.bit_depth) except Exception as err: self.logger.error(f"Error taking dark frame: {err!r}") self._autofocus_error = repr(err) focus_event.set() raise err # Take an image before focusing, grab a cutout from the centre and add it to the plot initial_fn = f"{initial_focus}-{focus_type}-initial.{self._camera.file_extension}" initial_path = os.path.join(file_path_root, initial_fn) try: initial_cutout = self._camera.get_cutout(seconds, initial_path, cutout_size, keep_file=True) initial_cutout = mask_saturated(initial_cutout, bit_depth=self.camera.bit_depth) if dark_cutout is not None: initial_cutout = initial_cutout.astype(np.int32) - dark_cutout except Exception as err: self.logger.error(f"Error taking initial image: {err!r}") self._autofocus_error = repr(err) focus_event.set() raise err # Set up encoder positions for autofocus sweep, truncating at focus travel # limits if required. if coarse: focus_range = focus_range[1] focus_step = focus_step[1] else: focus_range = focus_range[0] focus_step = focus_step[0] # Get focus steps. focus_positions = np.arange( max(initial_focus - focus_range / 2, self.min_position), min(initial_focus + focus_range / 2, self.max_position) + 1, focus_step, dtype=np.int) n_positions = len(focus_positions) # Set up empty array holders cutouts = np.zeros((n_positions, cutout_size, cutout_size), dtype=initial_cutout.dtype) masks = np.empty((n_positions, cutout_size, cutout_size), dtype=np.bool) metrics = np.empty(n_positions) # Take and store an exposure for each focus position. for i, position in enumerate(focus_positions): # Move focus, updating focus_positions with actual encoder position after move. focus_positions[i] = self.move_to(position) focus_fn = f"{focus_positions[i]}-{i:02d}.{self._camera.file_extension}" file_path = os.path.join(file_path_root, focus_fn) # Take exposure. try: cutouts[i] = self._camera.get_cutout(seconds, file_path, cutout_size, keep_file=keep_files) except Exception as err: self.logger.error(f"Error taking image {i + 1}: {err!r}") self._autofocus_error = repr(err) focus_event.set() raise err masks[i] = mask_saturated(cutouts[i], bit_depth=self.camera.bit_depth).mask self.logger.debug( f'Making master mask with binary dilation for {self._camera}') master_mask = masks.any(axis=0) master_mask = binary_dilation(master_mask, iterations=mask_dilations) # Apply the master mask and then get metrics for each frame. for i, cutout in enumerate(cutouts): self.logger.debug(f'Applying focus metric to cutout {i:02d}') if dark_cutout is not None: cutout = cutout.astype(np.float32) - dark_cutout cutout = np.ma.array(cutout, mask=np.ma.mask_or(master_mask, np.ma.getmask(cutout))) metrics[i] = focus_utils.focus_metric(cutout, merit_function, **merit_function_kwargs) self.logger.debug(f'Focus metric for cutout {i:02d}: {metrics[i]}') # Only fit a fine focus. fitted = False fitting_indices = [None, None] # Find maximum metric values. imax = metrics.argmax() if imax == 0 or imax == (n_positions - 1): # TODO: have this automatically switch to coarse focus mode if this happens self.logger.warning( f"Best focus outside sweep range, stopping focus and using" f" {focus_positions[imax]}") best_focus = focus_positions[imax] elif not coarse: # Fit data around the maximum value to determine best focus position. # Initialise models shift = models.Shift(offset=-focus_positions[imax]) # Small initial coeffs with expected sign. Helps fitting start in the right direction. poly = models.Polynomial1D(degree=4, c0=1, c1=0, c2=-1e-2, c3=0, c4=-1e-4, fixed={ 'c0': True, 'c1': True, 'c3': True }) scale = models.Scale(factor=metrics[imax]) # https://docs.astropy.org/en/stable/modeling/compound-models.html?#model-composition reparameterised_polynomial = shift | poly | scale # Initialise fitter fitter = fitting.LevMarLSQFitter() # Select data range for fitting. Tries to use 2 points either side of max, if in range. fitting_indices = (max(imax - 2, 0), min(imax + 2, n_positions - 1)) # Fit models to data fit = fitter( reparameterised_polynomial, focus_positions[fitting_indices[0]:fitting_indices[1] + 1], metrics[fitting_indices[0]:fitting_indices[1] + 1]) # Get the encoder position of the best focus. best_focus = np.abs(fit.offset_0) fitted = True # Guard against fitting failures, force best focus to stay within sweep range. min_focus = focus_positions[0] max_focus = focus_positions[-1] if best_focus < min_focus: self.logger.warning( f"Fitting failure: best focus {best_focus} below sweep limit" f" {min_focus}") best_focus = focus_positions[1] if best_focus > max_focus: self.logger.warning( f"Fitting failure: best focus {best_focus} above sweep limit" f" {max_focus}") best_focus = focus_positions[-2] else: # Coarse focus, just use max value. best_focus = focus_positions[imax] # Move the focuser to best focus position. final_focus = self.move_to(best_focus) # Get final cutout. final_fn = f"{final_focus}-{focus_type}-final.{self._camera.file_extension}" file_path = os.path.join(file_path_root, final_fn) try: final_cutout = self._camera.get_cutout(seconds, file_path, cutout_size, keep_file=True) final_cutout = mask_saturated(final_cutout, bit_depth=self.camera.bit_depth) if dark_cutout is not None: final_cutout = final_cutout.astype(np.int32) - dark_cutout except Exception as err: self.logger.error(f"Error taking final image: {err!r}") self._autofocus_error = repr(err) focus_event.set() raise err if make_plots: line_fit = None if fitted: focus_range = np.arange( focus_positions[fitting_indices[0]], focus_positions[fitting_indices[1]] + 1) fit_line = fit(focus_range) line_fit = [focus_range, fit_line] plot_title = f'{self._camera} {focus_type} focus at {start_time}' # Make the plots plot_path = os.path.join(file_path_root, f'{focus_type}-focus.png') plot_path = make_autofocus_plot(plot_path, initial_cutout, final_cutout, initial_focus, final_focus, focus_positions, metrics, merit_function, plot_title=plot_title, line_fit=line_fit) self.logger.info( f"{focus_type.capitalize()} focus plot for {self._camera} written to " f" {plot_path}") self.logger.debug(f"Autofocus of {self._camera} complete - final focus" f" position: {final_focus}") if focus_event: focus_event.set() return initial_focus, final_focus