Source code for opihiexarata.gui.selector

import sys
import os
import copy

import numpy as np
import scipy.optimize as sp_optimize

from PySide6 import QtCore, QtWidgets, QtGui

import matplotlib.cm as mpl_cm
import matplotlib.patches as mpl_patches
import matplotlib.pyplot as plt

# Using Qt backends.
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar


import opihiexarata.library as library
import opihiexarata.library.error as error
import opihiexarata.library.hint as hint

import opihiexarata.gui as gui


[docs] class TargetSelectorWindow(QtWidgets.QWidget): """This is the general class for the target selector window. The whole purpose of this class is for the ease of finding an asteroid. The GUI attribute elements are too numerous to list. Attributes ---------- current_filename : string The fits filename of the image which the position of the asteroid is going to be derived from. current_header : Header The fits header of the current fits image file. current_data : array The data of the of the current fits image file. reference_filename : string The fits filename of the image which is used to serve as an image to compare the current one to so that the asteroid is easier to find. reference_header : Header The fits header of the reference fits image file. reference_data : array The fits data of the reference fits image file. subtract_none : array The data after the comparison operation of doing nothing was applied. This serves mostly as a cache so that it only needs to be computed once. subtract_sidereal : array The data after the comparison operation of subtracting the two images. This serves mostly as a cache so that it only needs to be computed once. subtract_non_sidereal : array The data after the comparison operation of doing shifting then subtracting the two images. This serves mostly as a cache so that it only needs to be computed once. The non-sidereal rates of the current image are used. target_x : float The x pixel location of the asteroid in the current image. target_y : float The y pixel location of the asteroid in the current image. subtraction_method : string The method of subtraction (comparison) between the current image and the reference image. autoscale_1_99 : bool A flag to determine if, after every operation, the data's color bars should be scaled so that it is 1 - 99%, a helpful scaling. plotted_data : array The data as is plotted in the GUI. colorbar_scale_low : float The lower value for which the color bar determines as its 0, the lowest color value. reverse_colorbar : bool If True, for plotting only, reverse the colorbar. colorbar_scale_high : float The higher value for which the color bar determines as its 1, the highest color value. opihi_figure : Figure The matplotlib figure class of the displayed image in the GUI. opihi_axes : Axes The matplotlib axes class of the displayed image in the GUI. opihi_canvas : FigureCanvasQTAgg The matplotlib canvas class of the displayed image in the GUI. This uses matplotlib's built-in Qt support. opihi_nav_toolbar : NavigationToolbar2QT The matplotlib navigation bar class of the displayed image in the GUI. This uses matplotlib's built-in Qt support. _opihi_coordinate_formatter : CoordinateFormatter A class to wrap around the imshow formatter for fancy printing. """
[docs] def __init__( self, current_fits_filename: str, reference_fits_filename: str = None ) -> None: """Create the target selector window. Though often used for asteroids, there is no reason why is should specific to them; so we use a general name. Parameters ---------- current_fits_filename : string The current fits filename which will be used to determine where the location of the target is. reference_fits_filename : string, default = None The reference fits filename which will be used to compare against the current fits filename to determine where the location of the target is. If None, then no image will be loaded until manually specified. Returns ------- None """ # Creating the GUI itself using the Qt framework and the converted # Qt designer files. super(TargetSelectorWindow, self).__init__() self.ui = gui.qtui.Ui_SelectorWindow() self.ui.setupUi(self) # Window icon, we use the default for now. gui.functions.apply_window_icon(window=self, icon_path=None) # Window design parameters, just for show. self.setWindowTitle("OpihiExarata Target Selector") # The data from which will be shown to the user which they will use # to find the location of the target. current_header, current_data = library.fits.read_fits_image_file( filename=current_fits_filename ) self.current_filename = current_fits_filename self.current_header = current_header self.current_data = current_data # The reference data, if the fits file has been provided. Need to # take into account if the reference filename is not provided. if isinstance(reference_fits_filename, str) and os.path.isfile( reference_fits_filename ): reference_header, reference_data = library.fits.read_fits_image_file( filename=reference_fits_filename ) self.reference_filename = str(reference_fits_filename) self.reference_header = reference_header self.reference_data = reference_data else: # No data has been provided, just using sensible defaults. self.reference_filename = str(reference_fits_filename) self.reference_header = current_header.copy() self.reference_data = np.zeros_like(self.current_data) # Precompute the translated image array values to ensure the # cache speedup and subtraction capability. self._recompute_subtraction_arrays() # Dummy values for the box. self.box_search_x0 = 1 self.box_search_y0 = 1 self.box_search_x1 = 1 self.box_search_y1 = 1 # The location of the target, the user did not provide it so # blank currently. self.target_x = None self.target_y = None # There is currently no subtraction method provided. We assume # no subtraction so that the image can be shown. (Both None the type # and the string is valid as no subtraction. The type just means # that it has not been formally specified using the GUI.) self.subtraction_method = None # The data, as it is plotted. This will change with different # subtraction methodology. But, as the subtraction is defined as None, # the current data is fine. self.plotted_data = np.array(self.current_data) # We default the scale to the 1-99 automatic linear scale. It is # just an easier system and it makes the user think of it as a default. low, high = np.percentile(self.plotted_data, [1, 99]) self.colorbar_scale_low = float(low) self.colorbar_scale_high = float(high) self.reverse_colorbar = False # Making the plot itself. self.__init_opihi_image() # Adding the GUI connections. self.__init_gui_connections() # Redrawing the window before finishing up. self.refresh_window() # All done. return None
def __init_opihi_image(self) -> None: """Create the image area which will display what Opihi took from the sky. This takes advantage of a reserved image vertical layout in the design of the window. Parameters ---------- None Returns ------- None """ # Deriving the size of the image from the filler dummy image. The # figure should be a square. (Height really is the primary concern.) dpi = self.logicalDpiY() pix_to_in = lambda p: p / dpi dummy_edge_size_px = self.ui.dummy_selector_image.maximumHeight() edge_size_in = pix_to_in(dummy_edge_size_px) # The figure, canvas, and navigation toolbar of the image plot # using a Matplotlib Qt widget backend. We will add these to the # layout later. fig, ax = plt.subplots( figsize=(edge_size_in, edge_size_in), constrained_layout=True ) self.opihi_figure = fig self.opihi_axes = ax self.opihi_canvas = FigureCanvas(self.opihi_figure) self.opihi_nav_toolbar = NavigationToolbar(self.opihi_canvas, self) # The flag for determining if autoscaling should always be applied. # We default to whatever the Qt display has currently. self.autoscale_1_99 = bool(self.ui.check_box_autoscale_1_99.isChecked()) # For ease of usage, a custom navigation bar coordinate formatter # function/class is used. class CoordinateFormatter: """A simple function class to properly format the navigation bar coordinate text. This assumes the current structure of the GUI.""" def __init__(self, gui_instance: TargetSelectorWindow) -> None: self.gui_instance = gui_instance return None def __call__(self, x, y) -> str: """The coordinate string going to be put onto the navigation bar.""" # The pixel locations. x_index = int(x) y_index = int(y) x_coord_string = "{x_int:d}".format(x_int=x_index) y_coord_string = "{y_int:d}".format(y_int=y_index) # Extracting the data. try: z_float = self.gui_instance.plotted_data[y_index, x_index] except AttributeError: # There is no data to index. z_coord_string = "NaN" except IndexError: # The mouse is just outside of the boundary. z_coord_string = "NaN" else: # Parse the string from the number provided. z_coord_string = "{z_flt:.2f}".format(z_flt=z_float) # Compiling it all together. coord_string = "[{x_str}, {y_str}] = {z_str}".format( x_str=x_coord_string, y_str=y_coord_string, z_str=z_coord_string ) return coord_string # Assigning the coordinate formatter derived. self._opihi_coordinate_formatter = CoordinateFormatter(self) self.opihi_axes.format_coord = self._opihi_coordinate_formatter # Setting the layout, it is likely better to have the toolbar below # rather than above to avoid conflicts with the reset buttons in the # event of a mis-click. self.ui.vertical_layout_image.addWidget(self.opihi_canvas) self.ui.vertical_layout_image.addWidget(self.opihi_nav_toolbar) # Setting the size of the canvas to be more representative of the # designer file. self.opihi_canvas.setMinimumHeight(dummy_edge_size_px) self.opihi_canvas.setMaximumHeight(dummy_edge_size_px) self.opihi_canvas.setMinimumWidth(dummy_edge_size_px) self.opihi_canvas.setMaximumWidth(dummy_edge_size_px) # And setting the navigation bar. self.opihi_nav_toolbar.setMaximumWidth(dummy_edge_size_px) # Remove the dummy spacers otherwise it is just extra unneeded space. self.ui.vertical_layout_image.removeWidget(self.ui.dummy_selector_image) self.ui.vertical_layout_image.removeWidget(self.ui.dummy_selector_navbar) self.ui.dummy_selector_image.hide() self.ui.dummy_selector_navbar.hide() self.ui.dummy_selector_image.deleteLater() self.ui.dummy_selector_navbar.deleteLater() del self.ui.dummy_selector_image del self.ui.dummy_selector_navbar # A little hack to ensure the default zoom limits that are saved when # redrawing the figure is not 0-1 in both x and y but instead the image # itself. self.opihi_axes.set_xlim(0, self.current_data.shape[1]) self.opihi_axes.set_ylim(0, self.current_data.shape[0]) # Redraw the image. self.refresh_window() return None def __init_gui_connections(self): """A initiation set of functions that attach to the buttons on the GUI. Parameters ---------- None Returns ------- None """ # The connections for the fits file selection. self.ui.push_button_change_reference_filename.clicked.connect( self.__connect_push_button_change_reference_filename ) # The figure connections for dragging of the search box. The Opihi # image initialization should be done first. self.opihi_canvas.mpl_connect( "button_press_event", self.__connect_matplotlib_mouse_press_event ) self.opihi_canvas.mpl_connect( "button_release_event", self.__connect_matplotlib_mouse_release_event ) # The subtraction method connections for comparing the current # and release fits images. These buttons really just set the # subtraction method flag. self.ui.push_button_mode_none.clicked.connect( self.__connect_push_button_mode_none ) self.ui.push_button_mode_reference.clicked.connect( self.__connect_push_button_mode_reference ) self.ui.push_button_mode_sidereal.clicked.connect( self.__connect_push_button_mode_sidereal ) self.ui.push_button_mode_non_sidereal.clicked.connect( self.__connect_push_button_mode_non_sidereal ) # The scale and colorbar connections for determining the scale and # color bar information, along with the automatic way. self.ui.line_edit_dynamic_scale_low.editingFinished.connect( self.__connect_line_edit_dynamic_scale_low ) self.ui.line_edit_dynamic_scale_high.editingFinished.connect( self.__connect_line_edit_dynamic_scale_high ) self.ui.push_button_scale_1_99.clicked.connect( self.__connect_push_button_scale_1_99 ) self.ui.check_box_autoscale_1_99.stateChanged.connect( self.__connect_check_box_autoscale_1_99 ) self.ui.check_box_reverse_colorbar.stateChanged.connect( self.__connect_check_box_reverse_colorbar ) # The pixel location submission button connection. self.ui.push_button_submit_target.clicked.connect( self.__connect_push_button_submit_target ) return None def __connect_push_button_change_reference_filename(self) -> None: """This function provides a popup dialog to prompt the user to change the reference fits filename. Parameters ---------- None Returns ------- None """ # We assume, as a good jumping off point, that the reference filename # is in the same directory as the current image. current_image_directory = library.path.get_directory( pathname=self.current_filename ) # Use the build-in OS dialog box. new_reference_filename, __ = QtWidgets.QFileDialog.getOpenFileName( parent=self, caption="Open New Reference Opihi Image", dir=current_image_directory, filter="FITS Files (*.fits)", ) # If no file was provided, then there is nothing to do. if os.path.isfile(new_reference_filename): # Extracted the needed information provided this new fits file. reference_header, reference_data = library.fits.read_fits_image_file( filename=new_reference_filename ) self.reference_filename = new_reference_filename self.reference_header = reference_header self.reference_data = reference_data else: # Nothing to do. pass # Precompute the translated image array values to ensure the # cache speedup and subtraction capability. self._recompute_subtraction_arrays() # Redraw and refresh the window to use this new updated information. self.refresh_window() return None def __connect_matplotlib_mouse_press_event(self, event: hint.MouseEvent) -> None: """A function to describe what would happen when a mouse press is done on the Matplotlib image. This function defaults to the toolbar functionality when the toolbar is considered active. Parameters ---------- event : MouseEvent The event of the click itself. Returns ------- None """ # A tool on the toolbar is wanting to be used if the mode is non-blank, # prioritize the tool over the selector. if self.opihi_nav_toolbar.mode.value != "": # We proxy the middle button and left mouse button together for # zooming. We just allow it in addition. This is a band aid # solution. if event.button == 2 and self.opihi_nav_toolbar.mode.value == "zoom rect": event.button = 1 self.opihi_nav_toolbar.press_zoom(event=event) return None # If the button click was from the middle mouse button, a box is # being drawn for defining the box of the asteroid. if event.button == 2: # Assign the potential location of the target to the location # of the mouse. if event.xdata is None or event.ydata is None: # The user likely clicked outside of the canvas area. Ignore. pass else: # One of the bounds of the search rectangle. self.box_search_x0 = float(event.xdata) self.box_search_y0 = float(event.ydata) return None def __connect_matplotlib_mouse_release_event(self, event: hint.MouseEvent) -> None: """A function to describe what would happen when a mouse press is released done on the Matplotlib image. Parameters ---------- event : MouseEvent The event of the click itself. Returns ------- None """ # A tool on the toolbar is wanting to be used if the mode is non-blank, # prioritize the tool over the selector. if self.opihi_nav_toolbar.mode.value != "": # We proxy the middle button and left mouse button together for # zooming. This is a band aid solution. if event.button == 2 and self.opihi_nav_toolbar.mode.value == "zoom rect": event.button = 1 self.opihi_nav_toolbar.release_zoom(event=event) return None # If the button click was from the middle mouse button, a box is # being drawn for defining the box of the asteroid. if event.button == 2: # Assign the potential location of the target to the location # of the mouse. if event.xdata is None or event.ydata is None: # The user likely clicked outside of the canvas area. Ignore. pass else: # One of the bounds of the search rectangle. self.box_search_x1 = float(event.xdata) self.box_search_y1 = float(event.ydata) # By convention, the x0 <= x1 and vice versa for y. If the user drew # a backwards square, then we fix it here. if self.box_search_x1 < self.box_search_x0: lower_x = min([self.box_search_x0, self.box_search_x1]) upper_x = max([self.box_search_x0, self.box_search_x1]) self.box_search_x0 = lower_x self.box_search_x1 = upper_x if self.box_search_y1 < self.box_search_y0: lower_y = min([self.box_search_y0, self.box_search_y1]) upper_y = max([self.box_search_y0, self.box_search_y1]) self.box_search_y0 = lower_y self.box_search_y1 = upper_y # Find the target location based on the search area just determined. self.target_x, self.target_y = self.find_target_location( x0=self.box_search_x0, x1=self.box_search_x1, y0=self.box_search_y0, y1=self.box_search_y1, ) # Redrawing the image to have the location be visible. self.refresh_window() return None def __connect_push_button_mode_none(self) -> None: """This function sets the subtraction method to None, for comparing the current image from the reference image. Both None the type and the string is valid as no subtraction. The type just means that it has not been formally specified using the GUI. This method has no subtraction and thus no comparison to the reference image. Parameters ---------- None Returns ------- None """ # As the mode is being set by the GUI, we use the string form. self.subtraction_method = "none" # Because the subtraction mode changed, the data which is used to plot # should also be changed. self.plotted_data = self.subtract_none # Refresh the window because the method changed. self.refresh_window() return None def __connect_push_button_mode_reference(self) -> None: """This function sets the subtraction method to Reference, plotting the reference image instead of the current image. This method has no subtraction and thus no comparison to the current image. Parameters ---------- None Returns ------- None """ # As the mode is being set by the GUI, we use the string form. self.subtraction_method = "reference" # Because the subtraction mode changed, the data which is used to plot # should also be changed. self.plotted_data = self.subtract_reference # Refresh the window because the method changed. self.refresh_window() return None def __connect_push_button_mode_sidereal(self) -> None: """This function sets the subtraction method to sidereal, for comparing the current image from the reference image. This method assumes the approximation that both the current and reference images are pointing to the same point in the sky. Parameters ---------- None Returns ------- None """ # Setting the mode to sidereal. self.subtraction_method = "sidereal" # Because the subtraction mode changed, the data which is used to plot # should also be changed. self.plotted_data = self.subtract_sidereal # Refresh the window because the method changed. self.refresh_window() return None def __connect_push_button_mode_non_sidereal(self) -> None: """This function sets the subtraction method to non-sidereal, for comparing the current image from the reference image. This method assumes the approximation that the target itself did not move at all compared to both images, but the stars do as they are moving siderally. Parameters ---------- None Returns ------- None """ # Setting the mode to non-sidereal. self.subtraction_method = "non-sidereal" # Because the subtraction mode changed, the data which is used to plot # should also be changed. self.plotted_data = self.subtract_non_sidereal # Refresh the window because the method changed. self.refresh_window() return None def __connect_line_edit_dynamic_scale_low(self) -> None: """A function to operate on the change of the text of the low scale. Parameters ---------- None Returns ------- None """ # Saving the old lower bound scale value in the event that the text # entered cannot be properly converted. old_low_value = copy.deepcopy(self.colorbar_scale_low) # Try to get the new value. try: # Extracting the value the user provided in the text block. input_text = self.ui.line_edit_dynamic_scale_low.text() new_low_value = float(input_text) except Exception: # Something is wrong, the value entered is a valid entry; reverting # back to the defaults. new_low_value = old_low_value finally: self.colorbar_scale_low = new_low_value # If maximum value is less than the minimum value, it is likely the # user swapped them by mistake. We correct for the swapping here. if self.colorbar_scale_high <= self.colorbar_scale_low: # Storing to swap... raw_low = self.colorbar_scale_low raw_high = self.colorbar_scale_high # ...and swap. self.colorbar_scale_low = raw_high self.colorbar_scale_high = raw_low # Redraw the image with this new low colorbar. (Refreshing the # image itself is likely fine too.) self.refresh_window() # All done. return None def __connect_line_edit_dynamic_scale_high(self) -> None: """A function to operate on the change of the text of the high scale. Parameters ---------- None Returns ------- None """ # Saving the old higher bound scale value in the event that the text # entered cannot be properly converted. old_high_value = copy.deepcopy(self.colorbar_scale_high) # Try to get the new value. try: # Extracting the value the user provided in the text block. input_text = self.ui.line_edit_dynamic_scale_high.text() new_high_value = float(input_text) except Exception: # Something is wrong, the value entered is a valid entry; reverting # back to the defaults. new_high_value = old_high_value finally: self.colorbar_scale_high = new_high_value # If maximum value is less than the minimum value, it is likely the # user swapped them by mistake. We correct for the swapping here. if self.colorbar_scale_high <= self.colorbar_scale_low: # Storing to swap... raw_low = self.colorbar_scale_low raw_high = self.colorbar_scale_high # ...and swap. self.colorbar_scale_low = raw_high self.colorbar_scale_high = raw_low # Redraw the image with this new low colorbar. (Refreshing the # image itself is likely fine too.) self.refresh_window() # All done. return None def __connect_push_button_scale_1_99(self) -> None: """A function set the scale automatically to 1-99 within the region currently displayed on the screen. Parameters ---------- None Returns ------- None """ # We can autoscale to these values by recomputing the colorbar values. self._recompute_colorbar_autoscale(lower_percentile=1, higher_percentile=99) # Redraw the image with this new colorbar range. (Refreshing the # image itself is likely fine too.) self.refresh_window() # All done. return None def __connect_check_box_autoscale_1_99(self) -> None: """This check box allows the user to force the autoscaling of images when the subtraction method changes. Parameters ---------- None Return ------ None """ # Get the state of the check box. checkbox_state = self.ui.check_box_autoscale_1_99.isChecked() # As the mode is being set by the GUI, we use the string form. self.autoscale_1_99 = bool(checkbox_state) # Because this checkbox itself is expected to trigger scaling as well. if self.autoscale_1_99: self._recompute_colorbar_autoscale(lower_percentile=1, higher_percentile=99) # Refresh the window because the scaling has changed. self.refresh_window() return None def __connect_check_box_reverse_colorbar(self) -> None: """This check box allows the user to reverse the colors of the color bar. Parameters ---------- None Return ------ None """ # Get the state of the check box. checkbox_state = self.ui.check_box_reverse_colorbar.isChecked() # As the mode is being set by the GUI, we use the string form. self.reverse_colorbar = bool(checkbox_state) # Refresh the window because the scaling has changed. self.refresh_window() return None def __connect_push_button_submit_target(self) -> None: """This button submits the current location of the target and closes the window. (The target information is saved within the class instance.) If the text within the line edits differ than what the box selection has selected, then this prioritizes the values as manually defined. Although this should be rare as any time a box is drawn, the values and text boxes should be updated. If no entry is properly convertible, we default to center of the image. Parameters ---------- None Returns ------- None """ # The pixel locations as derived from the box mode. box_pixel_x = self.target_x box_pixel_y = self.target_y # The pixel locations as derived from the text entry. If they can # be converted into actual pixel locations, we prioritize them. try: entry_pixel_x = float(self.ui.line_edit_dynamic_target_x.text()) entry_pixel_y = float(self.ui.line_edit_dynamic_target_y.text()) except Exception: # The conversion cannot happen, the entry provided is not a valid # entry, going to use the box values instead. using_pixel_x = box_pixel_x using_pixel_y = box_pixel_y else: # Prioritizing the entered values. using_pixel_x = entry_pixel_x using_pixel_y = entry_pixel_y finally: # Type checking the currently assumed pixel values. if isinstance(using_pixel_x, (int, float)) and isinstance( using_pixel_y, (int, float) ): # The pixel values provided either through manual or box entry # is valid; prioritization has already been done. pass else: # The currently derived values are incorrect. Falling back on # an assumption of the beyond the origin to signify that it # was not provided while still giving a numerical value to # work with. using_pixel_x = -1 using_pixel_y = -1 # The target values updated to reflect this prioritization and # conversation. self.target_x = using_pixel_x self.target_y = using_pixel_y # All done, closing the window as we can now let the primary part of # the program continue. self.close_window() return None
[docs] def refresh_window(self) -> None: """Refresh the text content of the window given new information. This refreshes both the dynamic label text and redraws the image. Parameters ---------- None Returns ------- None """ # Rewriting the text... self.__refresh_image() # ...redrawing the image plot... self.__refresh_text() # All done. return None
def __refresh_image(self) -> None: """Redraw and refresh the image, this is mostly used to allow for the program to update where the user selected. Parameters ---------- None Returns ------- None """ # To retain the current zoom and pan, save the limits that the image # is currently at before redrawing. xmin, xmax = self.opihi_axes.get_xlim() ymin, ymax = self.opihi_axes.get_ylim() # If the user has set for autoscaling, then we apply the default # 1-99 % autoscaling. This must be done before the figure it cleared # before redrawing else it computes it from a single pixel image. if self.autoscale_1_99: self._recompute_colorbar_autoscale(lower_percentile=1, higher_percentile=99) # This is a function to replace the coordinate formatting in # favor of our own. def empty_string(string: str) -> str: return str() # Clearing the axes, starting fresh and anew as this entire function # does a whole redraw. It may not be needed but small performance # hit to ensure it all works normally. self.opihi_axes.clear() # The color map, as we are using grayscale, the bad pixels need to be # some other color. If the colormap is to be reversed, then do so # as well. raw_cmap = mpl_cm.get_cmap("gray") if self.reverse_colorbar: cmap = raw_cmap.reversed() else: cmap = raw_cmap cmap.set_bad(color="red") # Customizing the colorbar of our plotting image to match what the # current values are set at. image = self.opihi_axes.imshow( self.plotted_data, cmap=cmap, vmin=self.colorbar_scale_low, vmax=self.colorbar_scale_high, zorder=-1, ) # Disable their formatting in favor of ours. image.format_cursor_data = empty_string # If there is a specified target location, put it on the map. if isinstance(self.target_x, (int, float)) and isinstance( self.target_y, (int, float) ): # Represent the marker as the targets location as defined by the # search box and the target finding function. MARKER_SIZE = float( library.config.GUI_SELECTOR_IMAGE_PLOT_TARGET_MARKER_SIZE ) self.opihi_axes.scatter( self.target_x, self.target_y, s=MARKER_SIZE, marker="^", color="r", facecolors="None", ) # If there is a target, then the bounding box created must have # also succeeded. It is helpful for the user to also draw it. search_rectangle = mpl_patches.Rectangle( xy=(self.box_search_x0, self.box_search_y0), width=self.box_search_x1 - self.box_search_x0, height=self.box_search_y1 - self.box_search_y0, facecolor="None", edgecolor="b", ) self.opihi_axes.add_patch(search_rectangle) else: # No need, there is no current valid location specified. pass # Reinstate the zoom and pan settings via the previous limits. self.opihi_axes.set_xlim(xmin, xmax) self.opihi_axes.set_ylim(ymin, ymax) # Make sure the coordinate formatter does not change. self.opihi_axes.format_coord = self._opihi_coordinate_formatter # And finally, drawing the image. self.opihi_canvas.draw() # All done. return None def __refresh_text(self) -> None: """This function just refreshes the GUI text based on the current actual values. Parameters ---------- None Returns ------- None """ # Refreshing the current and reference fits filenames. No directory # information. current_bare_filename = library.path.get_filename_with_extension( pathname=self.current_filename ) reference_bare_filename = library.path.get_filename_with_extension( pathname=self.reference_filename ) self.ui.label_dynamic_current_fits_filename.setText(current_bare_filename) self.ui.label_dynamic_reference_fits_filename.setText(reference_bare_filename) # Refreshing the scale value text as set. Formatting the numerical # values into strings. scale_low_str = "{lo:.5f}".format(lo=self.colorbar_scale_low) scale_high_str = "{hi:.5f}".format(hi=self.colorbar_scale_high) self.ui.line_edit_dynamic_scale_low.setText(scale_low_str) self.ui.line_edit_dynamic_scale_high.setText(scale_high_str) # Refreshing the target pixel location in the manual entry. Formatting # the numerical values into strings. PRECISION = library.config.GUI_SELECTOR_TARGET_LOCATION_PIXEL_DECIMAL_PRECISION target_x = self.target_x if self.target_x is not None else np.nan target_y = self.target_y if self.target_y is not None else np.nan target_x_str = "{x:.{p}f}".format(x=target_x, p=int(PRECISION)) target_y_str = "{y:.{p}f}".format(y=target_y, p=int(PRECISION)) self.ui.line_edit_dynamic_target_x.setText(target_x_str) self.ui.line_edit_dynamic_target_y.setText(target_y_str) # All done. return None
[docs] def close_window(self) -> None: """Closes the window. Generally called when it is all done. Parameters ---------- None Returns ------- None """ # Delete the plot. This ensures that there is not memory leak with # many plots open over time. plt.close(self.opihi_figure) # Close the window. self.close() return None
[docs] def _recompute_subtraction_arrays(self) -> None: """This computes the subtracted arrays for both none, sidereal, and non-sidereal subtractions. This is done mostly for speed considerations as the values can be computed and stored during image loading. Parameters ---------- None Returns ------- None """ # Begin with computing no subtraction. This is really just the same # as the current data. self.subtract_none = self.current_data # The reference image mode is just the reference data without # any comparison to the current data. self.subtract_reference = self.reference_data # Subtracting sidereally implies that the center of the two images are # the same, so no translation is needed. self.subtract_sidereal = self.current_data - self.reference_data # Subtracting non-sidereally means that the centers are offset based # on the non-sidereal motion and time difference. We find the # translation vector between the two images. Because we are shifting # the reference image forward, the current data is the reference # for translation. x_pix_change, y_pix_change = library.image.determine_translation_image_array( translate_array=self.reference_data, reference_array=self.current_data ) # We shift the reference image forward in time as translation splines # and it is best not to interpolate the real data. We assume nothing # about the outside parts of the image, so there is no data for them. shifted_reference_data = library.image.translate_image_array( array=self.reference_data, shift_x=x_pix_change, shift_y=y_pix_change, pad_value=np.nan, ) self.subtract_non_sidereal = self.current_data - shifted_reference_data # All done. return None
[docs] def _recompute_colorbar_autoscale( self, lower_percentile: float = 1, higher_percentile: float = 99 ) -> None: """This is a function to recompute the autoscaling of the colorbar. This function needs to be split from the connection buttons otherwise an infinite loop occurs because of their inherent and expected calls to refresh the window. Parameters ---------- lower_percentile : float, default = 1 The lower percentile value which will be defined at the zero point for the colorbar. higher_percentile : float, default = 99 The higher (upper) percentile value which will be defined as the one point for the colorbar. Returns ------- None """ # Percentiles must be between 0 <= p <= 100. if not (0 <= lower_percentile <= 100 and 0 <= higher_percentile <= 100): raise error.InputError( "The percentiles given for the colorbar scaling are not between 0 and" " 100 as expected of percentiles." ) # The subset of the data that is currently displayed on the screen. xmin, xmax = self.opihi_axes.get_xlim() ymin, ymax = self.opihi_axes.get_ylim() displayed_plotted_image = self.plotted_data[ int(ymin) : int(ymax), int(xmin) : int(xmax) ] # Calculate the percentile values from this subarray as the colorbar # bounds. If the images was translated, there will be NaNs to deal # with. low, high = np.nanpercentile( displayed_plotted_image.flatten(), [lower_percentile, higher_percentile] ) self.colorbar_scale_low = low self.colorbar_scale_high = high # All done. return None
[docs] def find_target_location( self, x0: float, x1: float, y0: float, y1: float ) -> tuple[float, float]: """Find the location of a target by using a guessed location. The bounds of the search is specified by the rectangle. We use a quick way of finding the centroid based on summations on each axis. Parameters ---------- x0 : float The lower x axis bound of the search area. These values are cast into integers upon indexing the search area. x1 : float The upper x axis bound of the search area. These values are cast into integers upon indexing the search area. y0 : float The lower y axis bound of the search area. These values are cast into integers upon indexing the search area. y1 : float The upper y axis bound of the search area. These values are cast into integers upon indexing the search area. Returns ------- target_x : float The location of the target, based on the guess. target_y : float The location of the target, based on the guess. """ # Define the search area by the search radius. Being generous on the # search radius. x0 = int(x0) x1 = int(x1) + 1 y0 = int(y0) y1 = int(y1) + 1 search_array = self.current_data[ y0:y1, x0:x1, ] # We do not care about the baseline of the signal, it only gets in the # way. desky_search_array = search_array - np.nanmedian(search_array) # There are two ways we can do the finding of the centroid, either # using a set of Gaussian functions or just the maximum pixel. # We use the latter when the former breaks down. try: # Finding the center point based on the sums across each of the # axises. x_axis_collapse = np.sum(desky_search_array, axis=0) y_axis_collapse = np.sum(desky_search_array, axis=1) # It is a lot faster if we get a good guess as to the parameters. # Therefore, we guess the asteroid's location is the second highest. # The second highest should be close enough to the actual location # while also being robust to a hot pixel screwing things up. guess_search_x = np.argsort(x_axis_collapse)[-2] guess_search_y = np.argsort(y_axis_collapse)[-2] # Formatting the guesses. We use the actual sum value as well for # an estimation of the amplitude. guess_x_gaussian = [guess_search_x, 1, x_axis_collapse[guess_search_x]] guess_y_gaussian = [guess_search_y, 1, y_axis_collapse[guess_search_y]] # Using a 1-D Gaussian fit to determine the actual center along # each axis. def gaussian_function(x: hint.array, cen: float, std: float, amp: float): return amp * np.exp(-((x - cen) ** 2) / (2 * std**2)) # The x-axis is the pixel locations themselves with the y-axis # being the gaussian. We do not need the errors/covariances for # this. x_gaussian_param, __ = sp_optimize.curve_fit( gaussian_function, np.arange(len(x_axis_collapse)), x_axis_collapse, p0=guess_x_gaussian, method="trf", ) y_gaussian_param, __ = sp_optimize.curve_fit( gaussian_function, np.arange(len(y_axis_collapse)), y_axis_collapse, p0=guess_y_gaussian, method="trf", ) # We only need the gaussian centers, the asteroid pixel locations. search_x = float(x_gaussian_param[0]) search_y = float(y_gaussian_param[0]) except Exception: error.warn( warn_class=error.AccuracyWarning, message=( "The centroid of the star could not be computed using Gaussian" " cross sections; we are using maximum pixel instead." ), ) # Something happened and for some reason the Gaussian method did # not work as intended. We fall back to a far more robust but # inaccurate approach of determining the center based on the # maximum pixel. search_y, search_x = np.unravel_index( np.nanargmax(search_array), search_array.shape ) # Define the location of the target as the center of the maximum # pixel, not its edge. search_x = search_x + 0.5 search_y = search_y + 0.5 finally: # Transform it back into the total array coordinates. target_x = x0 + search_x target_y = y0 + search_y # All done. return target_x, target_y
[docs] def ask_user_target_selector_window( current_fits_filename, reference_fits_filename: str = None ) -> tuple[float, float]: """Use the target selector window to have the user provide the information needed to determine the location of the target. Parameters ---------- current_fits_filename : string The current fits filename which will be used to determine where the location of the target is. reference_fits_filename : string, default = None The reference fits filename which will be used to compare against the current fits filename to determine where the location of the target is. If None, then no image will be loaded until manually specified. Returns ------- target_x : float The location of the target in the x axis direction. target_y : float The location of the target in the y axis direction. """ # Create the target selector viewer window and let the user interact with # it until they let the answer be found. target_selector_window = TargetSelectorWindow( current_fits_filename=current_fits_filename, reference_fits_filename=reference_fits_filename, ) # Freeze all other processes until the location of the target has been # determined. This is a blocking process because everything else requires # this to be done. target_selector_window.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose) target_selector_window.show() # Using an event loop to wait until the widget closes, which is when # the user is done selecting the target location. loop = QtCore.QEventLoop() target_selector_window.destroyed.connect(loop.quit) loop.exec() # The extracted target pixel location values. target_x = target_selector_window.target_x target_y = target_selector_window.target_y # All done. return target_x, target_y
[docs] def main(): app = QtWidgets.QApplication([]) target_x, target_y = ask_user_target_selector_window() application.show() sys.exit(app.exec()) return target_x, target_y
if __name__ == "__main__": # This is really just to test the GUI, to actually use the GUI, please use # the proper function. application = QtWidgets.QApplication([]) ##### TESTING delta = 0.025 x = y = np.arange(-3.0, 3.0, delta) X, Y = np.meshgrid(x, y) Z1 = np.exp(-(X**2) - Y**2) Z2 = np.exp(-((X - 1) ** 2) - (Y - 1) ** 2) data_array = (Z1 - Z2) * 2 x, y = ask_user_target_selector_window(data_array=data_array) print("XY Coordinates: ", x, y) sys.exit()