def test_focus_metric_default(data_dir):
    data = fits.getdata(os.path.join(data_dir, 'unsolved.fits'))
    data = focus_utils.mask_saturated(data)
    assert focus_utils.focus_metric(data) == pytest.approx(14667.207897717599)
    assert focus_utils.focus_metric(
        data, axis='Y') == pytest.approx(14380.343807477504)
    assert focus_utils.focus_metric(
        data, axis='X') == pytest.approx(14954.071987957694)
    with pytest.raises(ValueError):
        focus_utils.focus_metric(data, axis='Z')
def test_focus_metric_bad_string(data_dir):
    data = fits.getdata(os.path.join(data_dir, 'unsolved.fits'))
    data = focus_utils.mask_saturated(data)
    with pytest.raises(KeyError):
        focus_utils.focus_metric(data, merit_function='NOTAMERITFUNCTION')
Beispiel #3
0
    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