From 814a8c377d735f5faaa1bfe01ef1b7035a49a11a Mon Sep 17 00:00:00 2001 From: prkrtg Date: Tue, 17 Sep 2024 15:37:58 -0700 Subject: [PATCH] COO-40 Cleanup atlas organization structure (#6) * COO-40 Cleanup atlas organization structure --- .github/workflows/pylint.yml | 2 +- .pylintrc | 2 +- fits_viewer.py | 624 ------------------- image_utils.py | 32 - main.py | 19 - src/main.py | 21 + src/model/fits_model.py | 62 ++ fits_viewer_ui.py => src/view/fits_viewer.py | 213 +++++-- histogram.py => src/view/histogram.py | 0 viewfinder.py => src/view/viewfinder.py | 1 - src/viewmodel/fits_viewmodel.py | 327 ++++++++++ 11 files changed, 588 insertions(+), 715 deletions(-) delete mode 100644 fits_viewer.py delete mode 100644 image_utils.py delete mode 100644 main.py create mode 100644 src/main.py create mode 100644 src/model/fits_model.py rename fits_viewer_ui.py => src/view/fits_viewer.py (54%) rename histogram.py => src/view/histogram.py (100%) rename viewfinder.py => src/view/viewfinder.py (98%) create mode 100644 src/viewmodel/fits_viewmodel.py diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 557cb79..4fc0a4e 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10"] + python-version: ["3.10", "3.11"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/.pylintrc b/.pylintrc index 2287ab6..c8627ff 100644 --- a/.pylintrc +++ b/.pylintrc @@ -39,7 +39,7 @@ extension-pkg-whitelist=PyQt5 fail-on= # Specify a score threshold under which the program will exit with error. -fail-under=10 +fail-under=7 # Interpret the stdin as a python script, whose filename needs to be passed as # the module_or_package argument. diff --git a/fits_viewer.py b/fits_viewer.py deleted file mode 100644 index 29a65bc..0000000 --- a/fits_viewer.py +++ /dev/null @@ -1,624 +0,0 @@ -# ----------------------------------------------------------------------------- -# @file fits_viewer.py -# @brief The FITSViewer app allows users to open and view FITS files. -# @author Prakriti Gupta -# ----------------------------------------------------------------------------- - -# pylint -# pylint: disable=line-too-long - -# Standard Library Imports -import os - -from fits_viewer_ui import FITSViewerUI -from image_utils import normalize_image, convert_to_qimage -from viewfinder import ViewfinderPopup -from histogram import Histogram - -# Third-Party Library Imports -from PyQt5.QtWidgets import QFileDialog, QDesktopWidget -from PyQt5.QtCore import Qt, QSize, QEvent, QRect -from PyQt5.QtGui import QPixmap, QImage -from astropy.io import fits -import numpy as np - -class FITSViewer(FITSViewerUI): - """ - Handles image processing and FITS file operations for the FITS Viewer application. - """ - def __init__(self): - - # Initialize viewfinder popup - self.viewfinder_popup = ViewfinderPopup() - self.mouse_pos = None - - super().__init__() - self.match_mode = False - self.cached_images = [None, None] - self.cached_headers = [None, None] - self.original_image = None - self.result_image = None - self.signal_image = None - self.reset_image = None - self.update_subtraction = False - self.image_dir = None - - def open_fits_image(self): - """ - Opens a directory dialog to select a folder, retrieves all FITS file paths from that folder, - and displays the first FITS image in the directory. - """ - file_name, _ = QFileDialog.getOpenFileName(self, "Open FITS File", "", "FITS Files (*.fits)") - if file_name: - self.display_fits_image(file_name) - - def open_fits_directory(self): - """ - Opens a directory selection dialog, retrieves the paths of all FITS files in the selected directory, - and initializes directory monitoring. - """ - self.image_dir = QFileDialog.getExistingDirectory(self, "Open Directory") - if self.image_dir: - self.update_images_in_directory() - - def check_for_new_images(self): - """ - Periodically checks the directory for new FITS images and updates the display. - """ - if self.image_dir: - self.update_images_in_directory() - - def update_images_in_directory(self): - """ - Retrieves the paths of all FITS files in the directory, updates the image sequence, - and displays the most recent images. - """ - if not self.image_dir: - return - - # Get all FITS files in the directory, sorted by modification time (most recent last) - file_paths = [os.path.join(self.image_dir, f) for f in os.listdir(self.image_dir) if f.lower().endswith('.fits')] - file_paths.sort(key=os.path.getmtime) # Sort by modification time - if self.match_mode: - # Handle the most recent two FITS images - if len(file_paths) >= 2: - self.image_paths = file_paths[-2:] # Take the two most recent files - self.current_image_index = 0 - # Display images in match mode - for i in range(len(self.image_paths)): - self.display_fits_image(self.image_paths[i]) - else: - print("Not enough images in the directory for match mode.") - else: - # Handle single image display when not in match mode - if file_paths: - self.image_paths = [file_paths[-1]] # Take the most recent file - self.current_image_index = 0 - self.display_fits_image(self.image_paths[self.current_image_index]) - - def display_fits_image(self, file_name): - """ - Displays a FITS image in the application based on the provided file name. - - Args: - file_name (str): The path to the FITS file to be displayed. - """ - with fits.open(file_name) as hdul: - image_data = hdul[0].data - header_info = hdul[0].header - - if image_data is None: - print("No data found in the FITS file.") - return - - self.original_image = image_data - print("Image data type:", image_data.dtype) - - if header_info is not None: - header_text = "\n".join([f"{key}: {header_info[key]}" for key in header_info]) - if self.cached_headers[0] is None or self.match_mode is False: - self.cached_headers[0] = header_text - elif self.cached_headers[1] is None: - self.cached_headers[1] = header_text - else: - self.cached_headers[0] = self.cached_headers[1] - self.cached_headers[1] = header_text - - if image_data.ndim == 2: - original_image = normalize_image(image_data) - q_image = convert_to_qimage(original_image) - pixmap = QPixmap.fromImage(q_image) - self.display_image(pixmap) - - elif image_data.ndim == 3 and image_data.shape[2] in [3, 4]: - q_image = convert_to_qimage(image_data) - pixmap = QPixmap.fromImage(q_image) - self.display_image(pixmap) - - elif image_data.ndim == 3: - print("Multispectral image detected. Displaying the first band.") - original_image = normalize_image(image_data[:, :, 0]) - q_image = convert_to_qimage(original_image) - pixmap = QPixmap.fromImage(q_image) - self.display_image(pixmap) - else: - print("Unsupported image format.") - - # Convert QImage to QPixmap and display - pixmap = QPixmap.fromImage(q_image) - - def update_result_label_size(self): - """ - Adjust result_label to match the size of image_label1 and image_label2 - """ - if self.image_label1.pixmap(): - size = self.image_label1.pixmap().size() - self.result_label.setFixedSize(size) - self.result_label.setPixmap(self.result_label.pixmap().scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation)) - else: - # Default size if image_label1 has no pixmap - self.result_label.setFixedSize(1000, 500) # or any default size you prefer - - def display_image(self, pixmap): - """ - Displays the given QPixmap in the appropriate image label based on the match mode. - Scales the image to fit within a maximum width and height, and manages cached images - for comparison when in match mode. - - Args: - pixmap (QPixmap): The QPixmap object containing the image to be displayed. - """ - screen = QDesktopWidget().screenGeometry() - max_width, max_height = screen.width() - 100, screen.height() - 100 # Adjust margins if needed - - # max_width, max_height = 1000, 1000 - scaled_pixmap = self.scale_pixmap(pixmap, max_width, max_height) - - if not self.match_mode: - self.cached_images[0] = pixmap - self.image_label2.setPixmap(QPixmap()) # Clear the second label - self.image_label1.setPixmap(self.cached_images[0].scaled(self.image_label1.size(), Qt.KeepAspectRatio)) - else: - if self.cached_images[0] is None: - self.cached_images[0] = pixmap - self.image_label1.setPixmap(self.cached_images[0].scaled(self.image_label1.size(), Qt.KeepAspectRatio)) - #self.image_label1.setPixmap(self.scale_pixmap(self.cached_images[0], max_width, max_height)) - elif self.cached_images[1] is None: - self.cached_images[1] = pixmap - #self.image_label2.setPixmap(self.scale_pixmap(self.cached_images[1], max_width, max_height)) - self.image_label2.setPixmap(self.cached_images[1].scaled(self.image_label1.size(), Qt.KeepAspectRatio)) - if self.update_subtraction: - self.subtract_from_images() - else: - self.cached_images[0] = self.cached_images[1] - self.cached_images[1] = pixmap - - self.image_label1.setPixmap(self.cached_images[0].scaled(self.image_label1.size(), Qt.KeepAspectRatio)) - self.image_label2.setPixmap(self.cached_images[1].scaled(self.image_label2.size(), Qt.KeepAspectRatio)) - - if self.update_subtraction: - self.subtract_from_images() - - - self.show_headers() - self.adjust_layout_for_match_mode() - - def scale_pixmap(self, pixmap, max_width, max_height): - """ - Scales a QPixmap to fit within a specified maximum width and height while maintaining the aspect ratio. - - Args: - pixmap (QPixmap): The QPixmap object to be scaled. - max_width (int): The maximum width for scaling the pixmap. - max_height (int): The maximum height for scaling the pixmap. - - Returns: - QPixmap: A new QPixmap object scaled to fit within the maximum width and height. - """ - original_size = pixmap.size() - scale_x = max_width / original_size.width() - scale_y = max_height / original_size.height() - scale = min(scale_x, scale_y) - return pixmap.scaled(original_size * scale, Qt.KeepAspectRatio, Qt.SmoothTransformation) - - def adjust_contrast(self): - if self.original_image is not None: - contrast = self.slider.value() / 50.0 - min_val = np.min(self.original_image) - adjusted_image = np.clip(min_val + (self.original_image - min_val) * contrast, 0, 255).astype(np.uint8) - q_image = convert_to_qimage(adjusted_image) - pixmap = QPixmap.fromImage(q_image) - self.display_image(pixmap) - - def show_headers(self): - """ - Display the cached headers in the header tab - """ - if self.cached_headers[0]: - self.header_text_area1.setPlainText(self.cached_headers[0]) - else: - self.header_text_area1.setPlainText("No header information available for Image 1.") - - if self.cached_headers[1]: - self.header_text_area2.setPlainText(self.cached_headers[1]) - else: - self.header_text_area2.setPlainText("No header information available for Image 2.") - - def toggle_match_mode(self, checked): - """ - Toggles the match mode on and off. - """ - self.match_mode = checked - if self.match_mode: - self.splitter.setVisible(True) - self.header_label2.setVisible(True) - self.header_text_area2.setVisible(True) - - if self.result_label.pixmap(): - # Update the result image to match the size of image_label1 and image_label2 - self.update_result_label_size() - result_pixmap = self.result_label.pixmap().scaled(self.result_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) - self.result_label.setPixmap(result_pixmap) - - else: - self.splitter.setVisible(False) - self.header_label2.setVisible(False) - self.header_text_area2.setVisible(False) - self.update_subtraction = False - - def show_header_tab(self): - """ - Switches to the Header tab in the tab widget. - """ - self.tab_widget.setCurrentWidget(self.header_tab) - - def toggle_header_visibility(self): - """ - Toggles the visibility of the header section. - """ - visible = not self.header_label1.isVisible() - self.header_label1.setVisible(visible) - self.header_text_area1.setVisible(visible) - self.header_label2.setVisible(visible) - self.header_text_area2.setVisible(visible) - - def adjust_layout_for_match_mode(self): - """ - Adjusts the layout when match mode is enabled or disabled. - """ - if self.match_mode: - self.splitter.setSizes([self.width() // 2, self.width() // 2]) - else: - self.splitter.setSizes([self.width(), 0]) - - def extract_tap_from_fits(self, image_data, tap_index): - """ - Extracts a specific TAP from the FITS image data. - - Parameters: - - image_data: The image data array. - - tap_index: Index of the TAP to extract. - - Returns: - - A 2D numpy array representing the specific TAP. - """ - if image_data.ndim == 3: - # Assuming the last dimension represents TAPs - return image_data[:, :, tap_index] - elif image_data.ndim == 2: - # If image_data is already 2D, no TAP extraction needed - return image_data - else: - raise ValueError("Unsupported image dimensions for TAP extraction.") - - def get_fits_image_data(self, pixmap): - """ - Convert a QPixmap object back to a numpy array. - Currently displays theQPixmap image data in Grayscale format. - """ - image = pixmap.toImage() - - # Ensure the QImage is in grayscale format - if image.format() != QImage.Format_Grayscale16: - image = image.convertToFormat(QImage.Format_Grayscale16) - - # Extract raw data from QImage - width, height = image.width(), image.height() - bytes_per_line = image.bytesPerLine() - raw_data = image.bits().asstring(bytes_per_line * height) - - # Convert raw data to numpy array - array = np.frombuffer(raw_data, dtype=np.uint16).reshape((height, width)) - return array - - def subtract_from_images(self): - """ - Subtracts one image from another and displays the result. - This method assumes that two images are cached and performs the subtraction - operation in a segmented manner to handle large images efficiently. - """ - if self.cached_images[0] is not None and self.cached_images[1] is not None: - - # If this is the first run, fetch the signal image from the first image - self.create_signal_fits() - - # Create the reset image - self.create_reset_fits() - - # Extract the QImage from the cached pixmaps - image1_data = self.signal_image - image2_data = self.reset_image - - result_array = np.zeros_like(image1_data, dtype=np.int16) - for tap_index in range(32): - # Calculate column indices for the tap - start_col = tap_index * 64 - end_col = start_col + 64 - - # Extract the specific tap from both images - tap1 = image1_data[:, start_col:end_col] - tap2 = image2_data[:, start_col:end_col] - - # Subtract corresponding taps - result_part = np.clip(tap2 - tap1, -32768, 32767).astype(np.int16) - # result_part = np.clip(tap2 - tap1, 0, 255).astype(np.int16) - - # Place the result into the corresponding section of the result_array - result_array[:, start_col:end_col] = result_part - - # Convert result to QImage - self.result_image = QImage(result_array.data, result_array.shape[1], result_array.shape[0], result_array.strides[0], QImage.Format_Grayscale16) - result_pixmap = QPixmap.fromImage(self.result_image) - - # Display images - self.image_label1.setPixmap(self.cached_images[0].scaled(self.image_label1.size(), Qt.KeepAspectRatio)) - self.image_label2.setPixmap(self.cached_images[1].scaled(self.image_label2.size(), Qt.KeepAspectRatio)) - self.result_label.setPixmap(result_pixmap.scaled(self.result_label.size(), Qt.KeepAspectRatio)) # Set result image on result_label - - def create_signal_fits(self, tap_width=128, num_taps=32): - """ - Creates a signal FITS image from the cached image data by extracting and processing - specific parts of the image. This method assumes that the cached image is in a format - where the image can be segmented into taps. - - Args: - tap_width (int): Width of each tap segment in the image. - num_taps (int): Number of tap segments to process. - """ - # Extract the QImage from the cached pixmaps - image1_data = self.get_fits_image_data(self.cached_images[0]) - - # Check if the data is 2D - if len(image1_data.shape) == 2: - height, width = image1_data.shape - - # Ensure the width and number of taps are consistent - if width == tap_width * (num_taps + 1): - # Initialize arrays for signal and reset images - self.signal_image = np.zeros((height, tap_width // 2 * num_taps), dtype=image1_data.dtype) - - # Extract signal and reset parts for each tap - for tap_index in range(num_taps): - # Calculate column indices for the tap - start_col = tap_index * tap_width - end_col = start_col + tap_width - - # Extract the specific tap - tap = image1_data[:, start_col:end_col] - - # Extract signal and reset parts - signal_part = tap[:, :64] - - # Place the signal and reset parts into the corresponding images - self.signal_image[:, tap_index * 64:(tap_index + 1) * 64] = signal_part - - # Create and save the FITS files for signal images - #TODO: Add a flag for this portion - # hdu_signal = fits.PrimaryHDU(signal_image) - - # hdul_signal = fits.HDUList([hdu_signal]) - - # hdul_signal.writeto(output_signal_file, overwrite=True) - - # print(f"Signal FITS image saved to {output_signal_file}") - - else: - print(f"Error: The width of the FITS image ({width}) does not match {tap_width} pixels per tap with {num_taps} taps.") - else: - print("The FITS file does not contain a 2D image. Please check the dimensionality.") - - def create_reset_fits(self, tap_width=128, num_taps=32): - """ - Creates a reset FITS image from the cached image data by extracting and processing - specific parts of the image. This method assumes that the cached image is in a - format where the image can be segmented into taps. - - Args: - tap_width (int): Width of each tap segment in the image. - num_taps (int): Number of tap segments to process. - """ - # Extract the QImage from the cached pixmaps - image2_data = self.get_fits_image_data(self.cached_images[1]) - - # Check if the data is 2D - if len(image2_data.shape) == 2: - height, width = image2_data.shape - - # Ensure the width and number of taps are consistent - if width == tap_width * (num_taps + 1): - # Initialize arrays for signal and reset images - self.reset_image = np.zeros((height, tap_width // 2 * num_taps), dtype=image2_data.dtype) - - # Extract signal and reset parts for each tap - for tap_index in range(num_taps): - # Calculate column indices for the tap - start_col = tap_index * tap_width - end_col = start_col + tap_width - - # Extract the specific tap - tap = image2_data[:, start_col:end_col] - - # Extract signal and reset parts - reset_part = tap[:, 64:] - - # Place the signal and reset parts into the corresponding images - self.reset_image[:, tap_index * 64:(tap_index + 1) * 64] = reset_part - - # Create and save the FITS files for reset images - #TODO: Add a flag for this portion - # hdu_reset = fits.PrimaryHDU(reset_image) - - # hdul_reset = fits.HDUList([hdu_reset]) - - # hdul_reset.writeto(output_reset_file, overwrite=True) - - # print(f"Reset FITS image saved to {output_reset_file}") - - else: - print(f"Error: The width of the FITS image ({width}) does not match {tap_width} pixels per tap with {num_taps} taps.") - else: - print("The FITS file does not contain a 2D image. Please check the dimensionality.") - - def reset(self): - """ - Resets the user interface and internal state of the application. - """ - # Clear all images and reset paths - self.image_label1.clear() - self.image_label2.clear() - self.result_label.clear() - - # Reset paths - # self.match_mode = False - self.cached_images = [None, None] - self.cached_headers = [None, None] - self.original_image = None - self.signal_image = None - self.reset_image = None - - # Optionally reset other elements here, e.g., text fields, selections, etc. - print("UI has been reset.") - - def open_viewfinder_popup(self): - """ - Opens the viewfinder popup and sets the pixmaps for the images. - """ - # Ensure cached_images and reset_image are not None - if (self.cached_images[0] is not None and - self.cached_images[1] is not None and - self is not None): - - # Convert QPixmap to QPixmap objects for the popup - image1_pixmap = self.cached_images[0] - image2_pixmap = self.cached_images[1] - result_pixmap = QPixmap.fromImage(self.result_image) - - # Set the images in the popup - self.viewfinder_popup.set_pixmaps(image1_pixmap, image2_pixmap, result_pixmap) - - # Show the popup - self.viewfinder_popup.exec_() - else: - print("Not enough images to display in the viewfinder popup.") - - def eventFilter(self, obj, event): - """ - Filters events to handle mouse movements over specific image labels. - Parameters: - - obj (QObject): The object that the event is associated with. This is typically one of - the image labels (e.g., `self.image_label1`, `self.image_label2`, - or `self.result_label`). - - event (QEvent): The event object containing information about the event. Specifically, - this method checks if the event type is `QEvent.MouseMove`. - """ - # Check if the event is a mouse move event - if event.type() == QEvent.MouseMove: - if obj == self.image_label1: - if self.cached_images[0]: - self.mouse_pos = event.pos() - self.current_label = 1 - self.update_viewfinder() - elif obj == self.image_label2: - if self.cached_images[1]: - self.mouse_pos = event.pos() - self.current_label = 2 - self.update_viewfinder() - elif obj == self.result_label: - if self.reset_image is not None: - self.mouse_pos = event.pos() - self.current_label = 3 - self.update_viewfinder() - - # Return False to ensure other event filters are still processed - return super().eventFilter(obj, event) - - def update_viewfinder(self): - """ - Updates the viewfinder popup with a cropped region of the image based on the mouse position. - - The method determines which image to use based on the current label and scales the image to fit - the label size. The viewfinder is updated only if a valid image is available and the mouse position is set. - """ - if self.mouse_pos: - # Determine which image to use based on the current label - if self.current_label == 1: - image_pixmap = self.cached_images[0] if self.cached_images[0] else None - elif self.current_label == 2: - image_pixmap = self.cached_images[1] if self.cached_images[1] else None - elif self.current_label == 3: - image_pixmap = QPixmap.fromImage(self.result_image) if self.result_image else None - else: - # No valid image is set, exit the method - return - - # Convert mouse position to the corresponding region in the image - pixmap_size = image_pixmap.size() - # Choose the appropriate size based on the current label - scaled_size = self.image_label1.size() if self.current_label in (1, 2) else self.result_label.size() - # Scale the image to match the label size - scaled_pixmap = image_pixmap.scaled(scaled_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) - - # Calculate the scale factors based on the image and label sizes - scale_x = scaled_size.width() / pixmap_size.width() - scale_y = scaled_size.height() / pixmap_size.height() - # Calculate the position of the viewfinder area in the image - viewfinder_x = int(self.mouse_pos.x() * scale_x) - viewfinder_y = int(self.mouse_pos.y() * scale_y) - - # Define the size of the viewfinder area - viewfinder_size = QSize(200, 200) - # Create a rectangle around the mouse position to show in the viewfinder - rect = QRect(viewfinder_x - viewfinder_size.width() // 2, - viewfinder_y - viewfinder_size.height() // 2, - viewfinder_size.width(), - viewfinder_size.height()) - - # Ensure the rectangle stays within the bounds of the image - rect = rect.intersected(QRect(0, 0, pixmap_size.width(), pixmap_size.height())) - - # Crop the region from the image based on the calculated rectangle - cropped_pixmap = image_pixmap.copy(rect) - - # Update the viewfinder popup with the cropped pixmap - self.viewfinder_popup.set_image(cropped_pixmap) - - def show_histogram(self): - """ - Opens a histogram dialog for the cached images and result image. - """ - if None in self.cached_images and self.result_image is None: - print("No images available for histogram.") - return - - # Combine cached images and result image into one list - images_to_display = [img for img in self.cached_images if img is not None] - if self.result_image is not None: - images_to_display.append(QPixmap.fromImage(self.result_image)) - - if not images_to_display: - print("No valid images available for histogram.") - return - - # Create and show the histogram dialog - histogram_dialog = Histogram(images_to_display, self) - histogram_dialog.exec_() diff --git a/image_utils.py b/image_utils.py deleted file mode 100644 index c40bb07..0000000 --- a/image_utils.py +++ /dev/null @@ -1,32 +0,0 @@ -import numpy as np -from PyQt5.QtGui import QImage - -def normalize_image(image_data): - """ - Normalizes the image data to a 0-255 range for display purposes. - """ - return np.uint8((image_data - np.min(image_data)) / np.ptp(image_data) * 255) - -def convert_to_qimage(image_data): - """ - Converts image data to QImage for display. - """ - if image_data.ndim == 2: - # Grayscale image - height, width = image_data.shape - q_image = QImage(image_data.data, width, height, width, QImage.Format_Grayscale8) - elif image_data.ndim == 3: - # RGB or RGBA image - height, width, channels = image_data.shape - bytes_per_line = channels * width - if channels == 3: - q_image = QImage(image_data.data, width, height, bytes_per_line, QImage.Format_RGB888) - elif channels == 4: - q_image = QImage(image_data.data, width, height, bytes_per_line, QImage.Format_RGBA8888) - else: - raise ValueError("Unsupported number of channels in image data.") - else: - raise ValueError("Unsupported image dimensions.") - - return q_image - diff --git a/main.py b/main.py deleted file mode 100644 index cc3ff3c..0000000 --- a/main.py +++ /dev/null @@ -1,19 +0,0 @@ -import sys -from PyQt5.QtWidgets import QApplication -from fits_viewer import FITSViewer - -def main(): - # Create the application instance - app = QApplication(sys.argv) - - # Create the main window instance - viewer = FITSViewer() - - # Show the main window - viewer.show() - - # Run the application event loop - sys.exit(app.exec_()) - -if __name__ == "__main__": - main() diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..876c9df --- /dev/null +++ b/src/main.py @@ -0,0 +1,21 @@ +import sys +from PyQt5.QtWidgets import QApplication +from view.fits_viewer import FITSViewer # Import the view +from viewmodel.fits_viewmodel import FITSViewModel # Import the view model + +def main(): + app = QApplication(sys.argv) + + # Initialize the view model + view_model = FITSViewModel() + + # Initialize the view with the view model + viewer = FITSViewer(view_model) + + # Show the main window + viewer.show() + + sys.exit(app.exec_()) + +if __name__ == '__main__': + main() diff --git a/src/model/fits_model.py b/src/model/fits_model.py new file mode 100644 index 0000000..9b2f65b --- /dev/null +++ b/src/model/fits_model.py @@ -0,0 +1,62 @@ +from PyQt5.QtGui import QImage +from astropy.io import fits +import numpy as np + +class FITSModel: + def load_fits_image(self, file_name): + """Load image data and header information from a FITS file.""" + with fits.open(file_name) as hdul: + image_data = hdul[0].data + header_info = hdul[0].header + return image_data, header_info + + def normalize_image(self, image_data): + """ + Normalizes image data to the range [0, 255] for display purposes, + handling various data types from FITS files. + + Args: + image_data (numpy.ndarray): The input image data to be normalized. + + Returns: + numpy.ndarray: The normalized image data scaled to the range [0, 255]. + """ + # Handle endianness and convert to little-endian if necessary + if image_data.dtype.byteorder == '>': + image_data = image_data.newbyteorder('<') + + # Convert image data to float for normalization + image_data = image_data.astype(np.float32) + + # Determine the min and max values + min_val = np.min(image_data) + max_val = np.max(image_data) + + # Special case handling for different data types + if image_data.dtype.kind in {'i', 'u'}: # Integer types + # Normalize to the range [0, 255] + normalized_data = 255 * (image_data - min_val) / (max_val - min_val) + elif image_data.dtype.kind == 'f': # Floating-point types + # Assuming floating-point data is already in a reasonable range + # Normalize to the range [0, 255] considering typical floating-point ranges + normalized_data = 255 * (image_data - min_val) / (np.ptp(image_data) if np.ptp(image_data) > 0 else 1) + else: + raise TypeError("Unsupported data type for normalization") + + # Clip values to the range [0, 255] + normalized_data = np.clip(normalized_data, 0, 255).astype(np.uint8) + + return normalized_data + + def convert_to_qimage(self, image_data): + """ + Convert numpy array image data to QImage. + """ + if image_data.ndim == 2: + height, width = image_data.shape + return QImage(image_data.data, width, height, width, QImage.Format_Grayscale8) + elif image_data.ndim == 3: + height, width, channels = image_data.shape + return QImage(image_data.data, width, height, width * channels, QImage.Format_RGB888) + else: + raise ValueError("Unsupported image data format.") diff --git a/fits_viewer_ui.py b/src/view/fits_viewer.py similarity index 54% rename from fits_viewer_ui.py rename to src/view/fits_viewer.py index cf41e7e..429f13d 100644 --- a/fits_viewer_ui.py +++ b/src/view/fits_viewer.py @@ -1,23 +1,29 @@ # ----------------------------------------------------------------------------- -# @file fits_viewer_ui.py -# @brief The FITSViewerUI Class handles the UI components and layout of the FITS Viewer. +# @file fits_viewer.py +# @brief The FITSViewer Class handles the UI components and layout of the FITS Viewer. # @author Prakriti Gupta # ----------------------------------------------------------------------------- -# pylint -# pylint: disable=line-too-long +from PyQt5.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QLabel, QTextEdit, + QSplitter, QSlider, QAction, QFileDialog, QTabWidget, QDesktopWidget) +from PyQt5.QtCore import Qt, QEvent +from PyQt5.QtGui import QPixmap +import numpy as np -from PyQt5.QtWidgets import (QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QLabel, QTextEdit, - QSplitter, QSlider, QAction, QFileDialog, QMenuBar, QPushButton, QTabWidget, QDesktopWidget) -from PyQt5.QtCore import Qt, QSizeF, QRectF -from PyQt5.QtGui import QPixmap, QImage +from .histogram import Histogram +from viewmodel.fits_viewmodel import FITSViewModel -class FITSViewerUI(QMainWindow): +class FITSViewer(QMainWindow): """ Handles the UI components and layout of the FITS Viewer application. """ - def __init__(self): + def __init__(self, view_model: FITSViewModel): + super().__init__() + self.fits_view_model = view_model + self.fits_view_model.image_data_changed.connect(self.display_image) + self.fits_view_model.result_ready.connect(self.update_result) + self.image_dir = None self.setup_ui() def setup_ui(self): @@ -115,13 +121,12 @@ def create_result_image_widget(self): screen_height = screen_size.height() # Set window size to be 80% of the screen size - self.result_label.setFixedSize(int(screen_width * 0.4), int(screen_height * 0.4)) + self.result_label.setFixedHeight(int(screen_height * 0.4)) self.result_label.setAlignment(Qt.AlignCenter) self.result_label.setMouseTracking(True) self.result_label.installEventFilter(self) self.result_image_widget = QWidget() - # self.result_image_widget.setStyleSheet("background-color: #E5E4E2;") self.result_image_layout = QVBoxLayout() self.result_image_layout.setAlignment(Qt.AlignCenter) self.result_image_widget.setLayout(self.result_image_layout) @@ -197,7 +202,7 @@ def create_file_menu(self): self.file_menu.addAction(self.open_directory_action) self.reset_action = QAction("Reset", self) - self.reset_action.triggered.connect(self.reset) + self.reset_action.triggered.connect(self.fits_view_model.reset) self.file_menu.addAction(self.reset_action) def create_connect_menu(self): @@ -206,8 +211,8 @@ def create_connect_menu(self): """ self.connect_menu = self.menu_bar.addMenu("Connect") - self.connect_redis_action = QAction("Connect to Redis", self) - self.connect_redis_action.triggered.connect(self.connect_to_redis) + self.connect_redis_action = QAction("Connect to Database", self) + self.connect_redis_action.triggered.connect(self.connect_to_db) self.connect_menu.addAction(self.connect_redis_action) def create_tools_menu(self): @@ -222,7 +227,7 @@ def create_tools_menu(self): self.tools_menu.addAction(self.match_mode_action) self.subtract_signal_action = QAction("Subtract Signal", self) - self.subtract_signal_action.triggered.connect(self.subtract_from_images) + self.subtract_signal_action.triggered.connect(self.fits_view_model.subtract_from_images) self.subtract_signal_action.setCheckable(True) self.tools_menu.addAction(self.subtract_signal_action) @@ -243,29 +248,31 @@ def create_view_menu(self): self.view_options_action.triggered.connect(self.view_options) self.view_menu.addAction(self.view_options_action) - # Add action to toggle viewfinder - self.viewfinder_action = QAction("Show Viewfinder", self, checkable=True) - self.viewfinder_action.triggered.connect(self.toggle_viewfinder) - self.view_menu.addAction(self.viewfinder_action) - - def toggle_viewfinder(self): - """ - Toggles the visibility of the viewfinder popup. - """ - if self.viewfinder_action.isChecked(): - self.viewfinder_popup.show() - self.viewfinder_visible = True - else: - self.viewfinder_popup.hide() - self.viewfinder_visible = False - def create_reset_button(self): """ Creates and configures the reset button on the menu bar. """ self.reset_action = QAction("Reset", self) - self.reset_action.triggered.connect(self.reset) + self.reset_action.triggered.connect(self.fits_view_model.reset) self.menu_bar.addAction(self.reset_action) + + def open_fits_image(self): + """ + Opens a directory dialog to select a folder, retrieves all FITS file paths from that folder, + and displays the first FITS image in the directory. + """ + file_name, _ = QFileDialog.getOpenFileName(self, "Open FITS File", "", "FITS Files (*.fits)") + if file_name: + self.fits_view_model.display_fits_image(file_name) + + def open_fits_directory(self): + """ + Opens a directory selection dialog, retrieves the paths of all FITS files in the selected directory, + and initializes directory monitoring. + """ + self.fits_view_model.image_dir = QFileDialog.getExistingDirectory(self, "Open Directory") + if self.fits_view_model.image_dir: + self.fits_view_model.update_images_in_directory() def toggle_header_visibility(self): """ @@ -287,18 +294,58 @@ def toggle_header_visibility(self): else: self.show_header_button.setText("Show Header") + def display_image(self, pixmap: QPixmap): + """ + Displays the given QPixmap in the appropriate image label based on the match mode. + Scales the image to fit within a maximum width and height, and manages cached images + for comparison when in match mode. + + Args: + pixmap (QPixmap): The QPixmap object containing the image to be displayed. + """ + screen = QDesktopWidget().screenGeometry() + max_width, max_height = screen.width() - 100, screen.height() - 100 # Adjust margins if needed + + # max_width, max_height = 1000, 1000 + scaled_pixmap = self.fits_view_model.scale_pixmap(pixmap, max_width, max_height) + + if not self.fits_view_model.match_mode: + self.fits_view_model.cached_images[0] = pixmap + self.image_label2.setPixmap(QPixmap()) # Clear the second label + self.image_label1.setPixmap(self.fits_view_model.cached_images[0].scaled(self.image_label1.size(), Qt.KeepAspectRatio)) + else: + if self.fits_view_model.cached_images[0] is None: + self.fits_view_model.cached_images[0] = pixmap + self.image_label1.setPixmap(self.fits_view_model.cached_images[0].scaled(self.image_label1.size(), Qt.KeepAspectRatio)) + elif self.fits_view_model.cached_images[1] is None: + self.fits_view_model.cached_images[1] = pixmap + self.image_label2.setPixmap(self.fits_view_model.cached_images[1].scaled(self.image_label1.size(), Qt.KeepAspectRatio)) + if self.fits_view_model.update_subtraction: + self.fits_view_model.subtract_from_images() + else: + self.fits_view_model.cached_images[0] = self.fits_view_model.cached_images[1] + self.fits_view_model.cached_images[1] = pixmap + + self.image_label1.setPixmap(self.fits_view_model.cached_images[0].scaled(self.image_label1.size(), Qt.KeepAspectRatio)) + self.image_label2.setPixmap(self.fits_view_model.cached_images[1].scaled(self.image_label2.size(), Qt.KeepAspectRatio)) + + if self.fits_view_model.update_subtraction: + self.fits_view_model.subtract_from_images() + + self.show_headers() + self.adjust_layout_for_match_mode() + def show_header_tab(self): """ Switches to the Header tab in the tab widget. """ self.tab_widget.setCurrentWidget(self.header_tab) - - def connect_to_redis(self): + def connect_to_db(self): """ - Handles the connection to Redis. + Handles the connection to selected database. """ - print("Connecting to Redis...") # Replace with actual Redis connection logic + print("Connecting to database...") def view_options(self): """ @@ -306,3 +353,95 @@ def view_options(self): """ print("Opening view options...") # Replace with actual view options logic + def adjust_layout_for_match_mode(self): + """ + Adjusts the layout when match mode is enabled or disabled. + """ + if self.fits_view_model.match_mode: + self.splitter.setSizes([self.width() // 2, self.width() // 2]) + else: + self.splitter.setSizes([self.width(), 0]) + + def update_result_label_size(self): + """ + Adjust result_label to match the size of image_label1 and image_label2 + """ + if self.image_label1.pixmap(): + size = self.image_label1.pixmap().size() + self.result_label.setFixedSize(size) + self.result_label.setPixmap(self.result_label.pixmap().scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation)) + else: + # Default size if image_label1 has no pixmap + self.result_label.setFixedSize(1000, 500) # or any default size you prefer + + def adjust_contrast(self): + if self.original_image is not None: + contrast = self.slider.value() / 50.0 + min_val = np.min(self.original_image) + adjusted_image = np.clip(min_val + (self.original_image - min_val) * contrast, 0, 255).astype(np.uint8) + q_image = self.fits_model.convert_to_qimage(adjusted_image) + pixmap = QPixmap.fromImage(q_image) + self.display_image(pixmap) + + def show_headers(self): + """ + Display the cached headers in the header tab + """ + if self.fits_view_model.cached_headers[0]: + self.header_text_area1.setPlainText(self.fits_view_model.cached_headers[0]) + else: + self.header_text_area1.setPlainText("No header information available for Image 1.") + + if self.fits_view_model.cached_headers[1]: + self.header_text_area2.setPlainText(self.fits_view_model.cached_headers[1]) + else: + self.header_text_area2.setPlainText("No header information available for Image 2.") + + def toggle_match_mode(self, checked): + """ + Toggles the match mode on and off. + """ + self.fits_view_model.match_mode = checked + if self.fits_view_model.match_mode: + self.splitter.setVisible(True) + self.header_label2.setVisible(True) + self.header_text_area2.setVisible(True) + + if self.result_label.pixmap(): + # Update the result image to match the size of image_label1 and image_label2 + self.update_result_label_size() + result_pixmap = self.result_label.pixmap().scaled(self.result_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) + self.result_label.setPixmap(result_pixmap) + + else: + self.splitter.setVisible(False) + self.header_label2.setVisible(False) + self.header_text_area2.setVisible(False) + self.fits_view_model.update_subtraction = False + + def update_result(self, result_pixmap: QPixmap): + """ + Updates the result image displayed in the viewer. + """ + self.result_label.setPixmap(result_pixmap) + + def show_histogram(self): + """ + Opens a histogram dialog for the cached images and result image. + """ + if None in self.fits_view_model.cached_images and self.fits_view_model.result_image is None: + print("No images available for histogram.") + return + + # Combine cached images and result image into one list + images_to_display = [img for img in self.fits_view_model.cached_images if img is not None] + if self.fits_view_model.result_image is not None: + images_to_display.append(QPixmap.fromImage(self.fits_view_model.result_image)) + + if not images_to_display: + print("No valid images available for histogram.") + return + + # Create and show the histogram dialog + histogram_dialog = Histogram(images_to_display, self) + histogram_dialog.exec_() diff --git a/histogram.py b/src/view/histogram.py similarity index 100% rename from histogram.py rename to src/view/histogram.py diff --git a/viewfinder.py b/src/view/viewfinder.py similarity index 98% rename from viewfinder.py rename to src/view/viewfinder.py index 405e6ca..7c8e4c0 100644 --- a/viewfinder.py +++ b/src/view/viewfinder.py @@ -6,7 +6,6 @@ from PyQt5.QtWidgets import QDialog, QVBoxLayout, QLabel, QComboBox from PyQt5.QtGui import QPixmap -from PyQt5.QtCore import QSize class ViewfinderPopup(QDialog): """ diff --git a/src/viewmodel/fits_viewmodel.py b/src/viewmodel/fits_viewmodel.py new file mode 100644 index 0000000..dd02acc --- /dev/null +++ b/src/viewmodel/fits_viewmodel.py @@ -0,0 +1,327 @@ +# ----------------------------------------------------------------------------- +# @file fits_viewmodel.py +# @brief The FITSViewModel manages FITS image data processing. +# @author Prakriti Gupta +# ----------------------------------------------------------------------------- + +from model.fits_model import FITSModel + +# Standard Library Imports +import os + +# Third-Party Library Imports +from PyQt5.QtCore import Qt, pyqtSignal, QObject +from PyQt5.QtGui import QPixmap, QImage +from astropy.io import fits +import numpy as np + +class FITSViewModel(QObject): + image_data_changed = pyqtSignal(QPixmap) + result_ready = pyqtSignal(QPixmap) + headers_updated = pyqtSignal(str, str) + + def __init__(self): + super().__init__() + self.fits_model = FITSModel() + self.cached_images = [None, None] + self.cached_headers = [None, None] + self.match_mode = False + self.original_image = None + self.result_image = None + self.signal_image = None + self.reset_image = None + self.image_dir = None + self.update_subtraction = False + + def display_fits_image(self, file_name): + """ + Displays a FITS image in the application based on the provided file name. + + Args: + file_name (str): The path to the FITS file to be displayed. + """ + with fits.open(file_name) as hdul: + image_data = hdul[0].data + header_info = hdul[0].header + + if image_data is None: + print("No data found in the FITS file.") + return + + self.original_image = image_data + print("Image data type:", image_data.dtype) + + if header_info is not None: + header_text = "\n".join([f"{key}: {header_info[key]}" for key in header_info]) + if self.cached_headers[0] is None or self.match_mode is False: + self.cached_headers[0] = header_text + elif self.cached_headers[1] is None: + self.cached_headers[1] = header_text + else: + self.cached_headers[0] = self.cached_headers[1] + self.cached_headers[1] = header_text + + if image_data.ndim == 2: + original_image = self.fits_model.normalize_image(image_data) + q_image = self.fits_model.convert_to_qimage(original_image) + pixmap = QPixmap.fromImage(q_image) + self.image_data_changed.emit(pixmap) + + elif image_data.ndim == 3 and image_data.shape[2] in [3, 4]: + q_image = self.fits_model.convert_to_qimage(image_data) + pixmap = QPixmap.fromImage(q_image) + self.image_data_changed.emit(pixmap) + + elif image_data.ndim == 3: + print("Multispectral image detected. Displaying the first band.") + original_image = self.fits_model.normalize_image(image_data[:, :, 0]) + q_image = self.fits_model.convert_to_qimage(original_image) + pixmap = QPixmap.fromImage(q_image) + self.image_data_changed.emit(pixmap) + else: + print("Unsupported image format.") + + # Convert QImage to QPixmap and display + pixmap = QPixmap.fromImage(q_image) + + def get_fits_image_data(self, pixmap): + """ + Convert a QPixmap object back to a numpy array. + Currently displays theQPixmap image data in Grayscale format. + """ + image = pixmap.toImage() + + # Ensure the QImage is in grayscale format + if image.format() != QImage.Format_Grayscale16: + image = image.convertToFormat(QImage.Format_Grayscale16) + + # Extract raw data from QImage + width, height = image.width(), image.height() + bytes_per_line = image.bytesPerLine() + raw_data = image.bits().asstring(bytes_per_line * height) + + # Convert raw data to numpy array + array = np.frombuffer(raw_data, dtype=np.uint16).reshape((height, width)) + return array + + def scale_pixmap(self, pixmap, max_width, max_height): + """ + Scales a QPixmap to fit within a specified maximum width and height while maintaining the aspect ratio. + + Args: + pixmap (QPixmap): The QPixmap object to be scaled. + max_width (int): The maximum width for scaling the pixmap. + max_height (int): The maximum height for scaling the pixmap. + + Returns: + QPixmap: A new QPixmap object scaled to fit within the maximum width and height. + """ + original_size = pixmap.size() + scale_x = max_width / original_size.width() + scale_y = max_height / original_size.height() + scale = min(scale_x, scale_y) + return pixmap.scaled(original_size * scale, Qt.KeepAspectRatio, Qt.SmoothTransformation) + + def update_images_in_directory(self): + """ + Retrieves the paths of all FITS files in the directory, updates the image sequence, + and displays the most recent images. + """ + if not self.image_dir: + return + + # Get all FITS files in the directory, sorted by modification time (most recent last) + file_paths = [os.path.join(self.image_dir, f) for f in os.listdir(self.image_dir) if f.lower().endswith('.fits')] + file_paths.sort(key=os.path.getmtime) # Sort by modification time + if self.match_mode: + # Handle the most recent two FITS images + if len(file_paths) >= 2: + self.image_paths = file_paths[-2:] # Take the two most recent files + self.current_image_index = 0 + # Display images in match mode + for i in range(len(self.image_paths)): + self.display_fits_image(self.image_paths[i]) + else: + print("Not enough images in the directory for match mode.") + else: + # Handle single image display when not in match mode + if file_paths: + self.image_paths = [file_paths[-1]] # Take the most recent file + self.current_image_index = 0 + self.display_fits_image(self.image_paths[self.current_image_index]) + + def extract_tap_from_fits(self, image_data, tap_index): + """ + Extracts a specific TAP from the FITS image data. + + Parameters: + - image_data: The image data array. + - tap_index: Index of the TAP to extract. + + Returns: + - A 2D numpy array representing the specific TAP. + """ + if image_data.ndim == 3: + # Assuming the last dimension represents TAPs + return image_data[:, :, tap_index] + elif image_data.ndim == 2: + # If image_data is already 2D, no TAP extraction needed + return image_data + else: + raise ValueError("Unsupported image dimensions for TAP extraction.") + + def subtract_from_images(self): + """ + Subtracts one image from another and displays the result. + This method assumes that two images are cached and performs the subtraction + operation in a segmented manner to handle large images efficiently. + """ + if self.cached_images[0] is not None and self.cached_images[1] is not None: + + # If this is the first run, fetch the signal image from the first image + self.create_signal_fits() + + # Create the reset image + self.create_reset_fits() + + # Extract the QImage from the cached pixmaps + image1_data = self.signal_image + image2_data = self.reset_image + + result_array = np.zeros_like(image1_data, dtype=np.int16) + for tap_index in range(32): + # Calculate column indices for the tap + start_col = tap_index * 64 + end_col = start_col + 64 + + # Extract the specific tap from both images + tap1 = image1_data[:, start_col:end_col] + tap2 = image2_data[:, start_col:end_col] + + # Subtract corresponding taps + result_part = np.clip(tap2 - tap1, -32768, 32767).astype(np.int16) + + # Place the result into the corresponding section of the result_array + result_array[:, start_col:end_col] = result_part + + # Convert result to QImage + self.result_image = QImage(result_array.data, result_array.shape[1], result_array.shape[0], result_array.strides[0], QImage.Format_Grayscale16) + result_pixmap = QPixmap.fromImage(self.result_image) + + # Emit signals to update the images in the view + self.result_ready.emit( + result_pixmap.scaled(self.result_image.size(), Qt.KeepAspectRatio) + ) + + def create_signal_fits(self, tap_width=128, num_taps=32): + """ + Creates a signal FITS image from the cached image data by extracting and processing + specific parts of the image. This method assumes that the cached image is in a format + where the image can be segmented into taps. + + Args: + tap_width (int): Width of each tap segment in the image. + num_taps (int): Number of tap segments to process. + """ + # Extract the QImage from the cached pixmaps + image1_data = self.get_fits_image_data(self.cached_images[0]) + + # Check if the data is 2D + if len(image1_data.shape) == 2: + height, width = image1_data.shape + + # Ensure the width and number of taps are consistent + if width == tap_width * (num_taps + 1): + # Initialize arrays for signal and reset images + self.signal_image = np.zeros((height, tap_width // 2 * num_taps), dtype=image1_data.dtype) + + # Extract signal and reset parts for each tap + for tap_index in range(num_taps): + # Calculate column indices for the tap + start_col = tap_index * tap_width + end_col = start_col + tap_width + + # Extract the specific tap + tap = image1_data[:, start_col:end_col] + + # Extract signal and reset parts + signal_part = tap[:, :64] + + # Place the signal and reset parts into the corresponding images + self.signal_image[:, tap_index * 64:(tap_index + 1) * 64] = signal_part + + # Create and save the FITS files for signal images + #TODO: Add a flag for this portion + # hdu_signal = fits.PrimaryHDU(signal_image) + + # hdul_signal = fits.HDUList([hdu_signal]) + + # hdul_signal.writeto(output_signal_file, overwrite=True) + + # print(f"Signal FITS image saved to {output_signal_file}") + + else: + print(f"Error: The width of the FITS image ({width}) does not match {tap_width} pixels per tap with {num_taps} taps.") + else: + print("The FITS file does not contain a 2D image. Please check the dimensionality.") + + def create_reset_fits(self, tap_width=128, num_taps=32): + """ + Creates a reset FITS image from the cached image data by extracting and processing + specific parts of the image. This method assumes that the cached image is in a + format where the image can be segmented into taps. + + Args: + tap_width (int): Width of each tap segment in the image. + num_taps (int): Number of tap segments to process. + """ + # Extract the QImage from the cached pixmaps + image2_data = self.get_fits_image_data(self.cached_images[1]) + + # Check if the data is 2D + if len(image2_data.shape) == 2: + height, width = image2_data.shape + + # Ensure the width and number of taps are consistent + if width == tap_width * (num_taps + 1): + # Initialize arrays for signal and reset images + self.reset_image = np.zeros((height, tap_width // 2 * num_taps), dtype=image2_data.dtype) + + # Extract signal and reset parts for each tap + for tap_index in range(num_taps): + # Calculate column indices for the tap + start_col = tap_index * tap_width + end_col = start_col + tap_width + + # Extract the specific tap + tap = image2_data[:, start_col:end_col] + + # Extract signal and reset parts + reset_part = tap[:, 64:] + + # Place the signal and reset parts into the corresponding images + self.reset_image[:, tap_index * 64:(tap_index + 1) * 64] = reset_part + + # Create and save the FITS files for reset images + #TODO: Add a flag for this portion + # hdu_reset = fits.PrimaryHDU(reset_image) + + # hdul_reset = fits.HDUList([hdu_reset]) + + # hdul_reset.writeto(output_reset_file, overwrite=True) + + # print(f"Reset FITS image saved to {output_reset_file}") + + else: + print(f"Error: The width of the FITS image ({width}) does not match {tap_width} pixels per tap with {num_taps} taps.") + else: + print("The FITS file does not contain a 2D image. Please check the dimensionality.") + + def reset(self): + """Reset the ViewModel state.""" + self.cached_images = [None, None] + self.cached_headers = [None, None] + self.original_image = None + self.result_image = None + self.signal_image = None + self.reset_image = None