diff --git a/fits_viewer.py b/fits_viewer.py index afc1c9d..311e15d 100644 --- a/fits_viewer.py +++ b/fits_viewer.py @@ -12,10 +12,11 @@ import sys from fits_viewer_ui import FITSViewerUI +from image_utils import normalize_image, convert_to_qimage # Third-Party Library Imports from PyQt5.QtWidgets import QMainWindow, QLabel, QVBoxLayout, QHBoxLayout, QPushButton, QSlider, QTextEdit, QFileDialog, QWidget, QSplitter - +from PyQt5.QtCore import Qt from PyQt5.QtGui import QPixmap, QImage from astropy.io import fits import numpy as np @@ -23,38 +24,39 @@ class FITSViewer(FITSViewerUI): """ Handles image processing and FITS file operations for the FITS Viewer application. - """ + """ def __init__(self): super().__init__() 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 self.setup_connections() def setup_connections(self): - # Connect buttons to their respective functions - self.open_file_button.clicked.connect(self.open_fits_image) - self.open_directory_button.clicked.connect(self.open_fits_directory) - self.match_mode_button.toggled.connect(self.toggle_match_mode) - self.header_button.clicked.connect(self.show_fits_header) + self.open_file_action.triggered.connect(self.open_fits_image) + self.open_directory_action.triggered.connect(self.open_fits_directory) + self.match_mode_action.toggled.connect(self.toggle_match_mode) + self.show_header_action.triggered.connect(self.show_header_tab) self.slider.valueChanged.connect(self.adjust_contrast) + self.subtract_signal_action.triggered.connect(self.subtract_from_images) def open_fits_image(self): """ - Opens a file dialog to select a single FITS file and displays it. + 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. """ - # Open a file dialog to select a FITS file - # NOTE: Ignores the filter type since we are not actively using it file_name, _ = QFileDialog.getOpenFileName(self, "Open FITS File", "", "FITS Files (*.fits)") if file_name: - # Load and display the FITS image self.display_fits_image(file_name) def open_fits_directory(self): """ - Opens a directory dialog to select a directory containing FITS files. + Opens a directory selection dialog, retrieves the paths of all FITS files in the selected directory, + and displays the first FITS image from the list of retrieved files. """ - # Open a directory dialog to select a directory of FITS files directory = QFileDialog.getExistingDirectory(self, "Open Directory") if directory: self.image_paths = [os.path.join(directory, f) for f in os.listdir(directory) if f.lower().endswith('.fits')] @@ -62,130 +64,378 @@ def open_fits_directory(self): if self.image_paths: self.display_fits_image(self.image_paths[self.current_image_index]) - def on_directory_changed(self, path): - """ - Refreshes the list of FITS files in the directory when it changes and updates the displayed image if necessary. - """ - print("To be implemented") - def display_fits_image(self, file_name): """ - Displays the FITS image from the specified file. Supports grayscale, RGB, and RGBA images. + 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: - self.image_data = hdul[0].data - self.header_info = hdul[0].header + image_data = hdul[0].data + header_info = hdul[0].header - if self.image_data is None: + if image_data is None: print("No data found in the FITS file.") return - - if self.header_info is not None: - header_text = "\n".join([f"{key}: {self.header_info[key]}" for key in self.header_info]) - self.header_info = header_text - if self.image_data.ndim == 2: - self.original_image = self.normalize_image(self.image_data) - q_image = self.convert_to_qimage(self.original_image) + 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 self.image_data.ndim == 3 and self.image_data.shape[2] in [3, 4]: - q_image = self.convert_to_qimage(self.image_data) + 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 self.image_data.ndim == 3: + elif image_data.ndim == 3: print("Multispectral image detected. Displaying the first band.") - self.original_image = self.normalize_image(self.image_data[:, :, 0]) - q_image = self.convert_to_qimage(self.original_image) + 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.") + print("Unsupported image format.") + + # Convert QImage to QPixmap and display + pixmap = QPixmap.fromImage(q_image) + + # Center and scale result image if displayed + if self.result_label.pixmap(): + result_pixmap = pixmap.scaled(self.result_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) + self.result_label.setPixmap(result_pixmap) + + 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 image based on whether match mode is enabled or not. + 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. """ max_width, max_height = 1000, 1000 - - # Calculate the scaling factors scaled_pixmap = self.scale_pixmap(pixmap, max_width, max_height) if not self.match_mode: - # Single image mode - self.image_label1.setPixmap(scaled_pixmap) - self.image_label2.setPixmap(QPixmap()) # Clear the second label - self.resize(scaled_pixmap.size()) - self.cached_headers[0] = self.header_info - + self.image_label1.setPixmap(scaled_pixmap) + self.image_label2.setPixmap(QPixmap()) # Clear the second label + self.resize(scaled_pixmap.size()) else: - # Match mode - if self.cached_images[0] is None: - # Cache the first image - self.cached_images[0] = scaled_pixmap - self.image_label1.setPixmap(self.cached_images[0]) - self.cached_headers[0] = self.header_info - elif self.cached_images[1] is None: - # Cache the second image - self.cached_images[1] = scaled_pixmap - self.image_label2.setPixmap(self.cached_images[1]) - self.cached_headers[1] = self.header_info - else: - # Both images are cached, shift the images - self.cached_images[0] = self.cached_images[1] # Move the second image to the first slot - self.cached_headers[0] = self.cached_headers[1] - self.cached_images[1] = scaled_pixmap # Add the new image as the second image - self.cached_headers[1] = self.header_info + if self.cached_images[0] is None: + self.cached_images[0] = pixmap + 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)) + else: + self.cached_images[0] = self.cached_images[1] + self.cached_images[1] = pixmap - # Display the updated images - self.image_label1.setPixmap(self.scale_pixmap(self.cached_images[0], max_width, max_height)) - self.image_label2.setPixmap(self.scale_pixmap(self.cached_images[1], max_width, max_height)) + self.image_label1.setPixmap(self.scale_pixmap(self.cached_images[0], max_width, max_height)) + self.image_label2.setPixmap(self.scale_pixmap(self.cached_images[1], max_width, max_height)) - # Adjust the layout based on match mode - self.adjust_layout_for_match_mode() + self.show_headers() + self.adjust_layout_for_match_mode() - def normalize_image(self, image_data): + def scale_pixmap(self, pixmap, max_width, max_height): """ - Normalizes the image data to a 0-255 range for display purposes. + 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. """ - return np.uint8((image_data - np.min(image_data)) / np.ptp(image_data) * 255) + 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 convert_to_qimage(self, image_data): + def show_headers(self): """ - Converts RGB or RGBA image data to a QImage for display. + Display the cached headers in the header tab """ - 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.") + if self.cached_headers[0]: + self.header_text_area1.setPlainText(self.cached_headers[0]) else: - raise ValueError("Unsupported image dimensions.") + self.header_text_area1.setPlainText("No header information available for Image 1.") - return q_image + 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 show_fits_header(self): + def toggle_match_mode(self, checked): """ - Displays the FITS header information in the header text areas. - If match mode is enabled, it shows the headers for both images. - If no images are present, prompts the user to open an image. + Toggles the match mode on and off. """ + self.match_mode = checked if self.match_mode: - # Update the header text areas for match mode - self.header_text_area1.setText(self.cached_headers[0]) - self.header_text_area2.setText(self.cached_headers[1]) + self.splitter.setVisible(True) + self.header_label2.setVisible(True) + self.header_text_area2.setVisible(True) + + # Update the result image to match the size of image_label1 and image_label2 + self.update_result_label_size() + if self.result_label.pixmap(): + 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) + + 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 + 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(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[0]) + + # 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: - # Show the header for the single image mode - print(self.cached_headers[0]) - self.header_text_area1.setText(self.cached_headers[0]) + print("The FITS file does not contain a 2D image. Please check the dimensionality.") diff --git a/fits_viewer_ui.py b/fits_viewer_ui.py index 6cc2197..583df1b 100644 --- a/fits_viewer_ui.py +++ b/fits_viewer_ui.py @@ -1,4 +1,14 @@ -from PyQt5.QtWidgets import QMainWindow, QLabel, QVBoxLayout, QHBoxLayout, QPushButton, QSlider, QTextEdit, QFileDialog, QWidget, QSplitter +# ----------------------------------------------------------------------------- +# @file fits_viewer_ui.py +# @brief The FITSViewerUI 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, QHBoxLayout, QVBoxLayout, QLabel, QTextEdit, + QSplitter, QSlider, QAction, QFileDialog, QMenuBar, QPushButton, QTabWidget) from PyQt5.QtCore import Qt from PyQt5.QtGui import QPixmap, QImage @@ -6,83 +16,125 @@ class FITSViewerUI(QMainWindow): """ Handles the UI components and layout of the FITS Viewer application. """ - def __init__(self): super().__init__() - - # Initialize UI components self.setup_ui() def setup_ui(self): """ Sets up the user interface of the FITSViewer. """ - # Set up the window self.setWindowTitle("FITS Image Viewer") - self.setGeometry(100, 100, 1000, 1000) # Default size + self.setGeometry(100, 100, 1200, 1000) - # Set up the central widget and layout self.central_widget = QWidget() self.setCentralWidget(self.central_widget) self.main_layout = QVBoxLayout() self.central_widget.setLayout(self.main_layout) - # Create a horizontal layout for the buttons - self.button_layout = QHBoxLayout() + self.create_tab_widget() + self.create_image_tab() + self.create_header_tab() + self.create_menu_bar() - # Create and add buttons to the horizontal layout - self.open_file_button = QPushButton("Open FITS Image") - self.open_file_button.clicked.connect(self.open_fits_image) - self.button_layout.addWidget(self.open_file_button) + def create_tab_widget(self): + """ + Creates and configures the tab widget. + """ + self.tab_widget = QTabWidget() + self.main_layout.addWidget(self.tab_widget) - self.open_directory_button = QPushButton("Open Directory of FITS Images") - self.open_directory_button.clicked.connect(self.open_fits_directory) - self.button_layout.addWidget(self.open_directory_button) + def create_image_tab(self): + """ + Creates and configures the image tab. + """ + self.image_tab = QWidget() + self.image_tab_layout = QVBoxLayout() + self.image_tab.setLayout(self.image_tab_layout) - # Create the Match Mode button - self.match_mode_button = QPushButton("Match Mode") - self.match_mode_button.setCheckable(True) # Allow the button to be toggled - self.match_mode_button.toggled.connect(self.toggle_match_mode) - self.button_layout.addWidget(self.match_mode_button) + self.create_image_splitter() + self.create_contrast_slider() - # Add the button layout to the main layout - self.main_layout.addLayout(self.button_layout) + self.tab_widget.addTab(self.image_tab, "Images") - # Create and add the image label and slider - self.image_label1 = QLabel() - self.image_label2 = QLabel() + def create_image_splitter(self): + """ + Creates and configures the image splitter and its components. + """ + self.splitter = QSplitter(Qt.Vertical) + self.splitter.setVisible(True) + self.image_tab_layout.addWidget(self.splitter) - #self.image_layout = QHBoxLayout() - #self.image_layout.addWidget(self.image_label1) - #self.image_layout.addWidget(self.image_label2) - #self.main_layout.addLayout(self.image_layout) + self.create_image_layout() + self.create_result_image_widget() - # Create the splitter for match mode - self.splitter = QSplitter(Qt.Horizontal) - self.splitter.addWidget(self.image_label1) - self.splitter.addWidget(self.image_label2) + self.image_splitter_widget = QWidget() + self.image_splitter_layout = QVBoxLayout() + self.image_splitter_widget.setLayout(self.image_splitter_layout) + self.image_splitter_layout.addLayout(self.image_layout) - # Set initial sizes for both images - self.splitter.setSizes([self.width() // 2, self.width() // 2]) + self.splitter.addWidget(self.image_splitter_widget) + self.splitter.addWidget(self.result_image_widget) - # Add the splitter to the main layout - self.main_layout.addWidget(self.splitter) + def create_image_layout(self): + """ + Creates and configures the layout for images. + """ + self.image_layout = QVBoxLayout() + self.image_labels_layout = QHBoxLayout() + self.image_label1 = QLabel() + self.image_label2 = QLabel() + self.image_labels_layout.addWidget(self.image_label1) + self.image_labels_layout.addWidget(self.image_label2) + + self.image_layout.addLayout(self.image_labels_layout) + def create_result_image_widget(self): + """ + Creates and configures the result image widget. + """ + self.result_label = QLabel() + self.result_label.setFixedSize(1000, 500) + self.result_label.setAlignment(Qt.AlignCenter) + + 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) + self.result_image_layout.addWidget(self.result_label) + + def create_contrast_slider(self): + """ + Creates and configures the contrast adjustment slider. + """ self.slider = QSlider(Qt.Horizontal) - self.slider.setMinimum(0) - self.slider.setMaximum(100) - self.slider.setValue(50) # Default value for contrast - self.slider.setTickInterval(10) + self.slider.setRange(0, 100) + self.slider.setValue(50) self.slider.setTickPosition(QSlider.TicksBelow) + self.slider.setTickInterval(10) + self.slider.setSingleStep(1) + self.slider.setToolTip("Adjust contrast") + + self.image_tab_layout.addWidget(self.slider) self.slider.valueChanged.connect(self.adjust_contrast) - self.main_layout.addWidget(self.slider) - # Create and add the FITS header button and text area - self.header_button = QPushButton("View FITS Header") - self.header_button.clicked.connect(self.show_fits_header) - self.button_layout.addWidget(self.header_button) + def create_header_tab(self): + """ + Creates and configures the header tab. + """ + self.header_tab = QWidget() + self.header_layout = QVBoxLayout() + self.header_tab.setLayout(self.header_layout) + + self.create_header_text_areas() - # Create and add the FITS header text areas + self.tab_widget.addTab(self.header_tab, "Header") + + def create_header_text_areas(self): + """ + Creates and configures the FITS header text areas. + """ self.header_label1 = QLabel("Header 1:") self.header_text_area1 = QTextEdit() self.header_text_area1.setReadOnly(True) @@ -90,94 +142,86 @@ def setup_ui(self): self.header_label2 = QLabel("Header 2:") self.header_text_area2 = QTextEdit() self.header_text_area2.setReadOnly(True) - self.header_label2.setVisible(False) - self.header_text_area2.setVisible(False) - self.header_layout = QVBoxLayout() self.header_layout.addWidget(self.header_label1) self.header_layout.addWidget(self.header_text_area1) self.header_layout.addWidget(self.header_label2) self.header_layout.addWidget(self.header_text_area2) - self.main_layout.addLayout(self.header_layout) - - # Initialize both image labels to be empty - self.image_label1.setPixmap(QPixmap()) - self.image_label2.setPixmap(QPixmap()) - - def toggle_match_mode(self, checked): + def create_menu_bar(self): """ - Toggles the match mode on and off. + Creates and configures the menu bar. """ - self.match_mode = checked - if self.match_mode: - self.match_mode_button.setStyleSheet("background-color: lightgreen;") # Change button color when match mode is enabled - self.splitter.setVisible(True) # Show the splitter in match mode - self.header_label2.setVisible(True) - self.header_text_area2.setVisible(True) - else: - self.match_mode_button.setStyleSheet("background-color: lightgray;") # Change button color when match mode is disabled - self.splitter.setVisible(False) - self.header_label2.setVisible(False) - self.header_text_area2.setVisible(False) - - def adjust_layout_for_match_mode(self): - """ - Adjusts the layout to display images side by side in match mode. - """ - if self.match_mode: - self.image_label1.setVisible(True) - self.image_label2.setVisible(True) - if self.cached_images[0] is not None and self.cached_images[1] is not None: - # Calculate the combined size based on the larger dimension - img1_size = self.cached_images[0].size() - img2_size = self.cached_images[1].size() - combined_width = img1_size.width() + img2_size.width() - combined_height = max(img1_size.height(), img2_size.height()) - self.resize(combined_width, combined_height) # Resize the window to fit both images - else: - self.resize(1000, 1000) # Default size or handle as needed - else: - # Single image mode - self.image_label1.setVisible(True) - self.image_label2.setVisible(False) # Hide the second label - self.resize(self.image_label1.pixmap().size()) # Resize to fit the single image + self.menu_bar = self.menuBar() + self.create_file_menu() + self.create_tools_menu() - def scale_pixmap(self, pixmap, max_width, max_height): + def create_file_menu(self): """ - Scales the QPixmap to fit within the specified maximum width and height while preserving the aspect ratio. + Creates and configures the File menu. """ - # Get the original size of the pixmap - original_size = pixmap.size() + self.file_menu = self.menu_bar.addMenu("File") + + self.open_file_action = QAction("Open FITS Image", self) + self.open_file_action.triggered.connect(self.open_fits_image) + self.file_menu.addAction(self.open_file_action) + + self.open_directory_action = QAction("Open Directory of FITS Images", self) + self.open_directory_action.triggered.connect(self.open_fits_directory) + self.file_menu.addAction(self.open_directory_action) - # Calculate the scaling factors - scale_x = max_width / original_size.width() - scale_y = max_height / original_size.height() + def create_tools_menu(self): + """ + Creates and configures the Tools menu. + """ + self.tools_menu = self.menu_bar.addMenu("Tools") - # Choose the smaller scaling factor to ensure the pixmap fits within the max dimensions - scale = min(scale_x, scale_y) + self.match_mode_action = QAction("Match Mode", self) + self.match_mode_action.setCheckable(True) + self.match_mode_action.toggled.connect(self.toggle_match_mode) + self.tools_menu.addAction(self.match_mode_action) - # Scale the pixmap - scaled_pixmap = pixmap.scaled(original_size * scale, Qt.KeepAspectRatio, Qt.SmoothTransformation) + self.subtract_signal_action = QAction("Subtract Signal", self) + self.subtract_signal_action.triggered.connect(self.subtract_from_images) + self.tools_menu.addAction(self.subtract_signal_action) - return scaled_pixmap + self.show_header_action = QAction("Show Header", self) + self.show_header_action.triggered.connect(self.show_header_tab) + self.tools_menu.addAction(self.show_header_action) - def adjust_contrast(self): + def toggle_header_visibility(self): """ - Adjusts the contrast of the displayed image based on the slider value. + Toggles the visibility of the header section. """ - if self.original_image is not None: - # Get the contrast value from the slider - contrast = self.slider.value() / 50.0 # Scale factor, default is 1.0 + # Check if the header section is currently visible + is_visible = self.header_label1.isVisible() + + # Set visibility based on the current state + new_visibility = not is_visible + self.header_label1.setVisible(new_visibility) + self.header_text_area1.setVisible(new_visibility) + self.header_label2.setVisible(new_visibility) + self.header_text_area2.setVisible(new_visibility) + + # Update the button text to reflect the current state + if new_visibility: + self.show_header_button.setText("Hide Header") + else: + self.show_header_button.setText("Show Header") - # Adjust the contrast - min_val = np.min(self.original_image) - adjusted_image = np.clip(min_val + (self.original_image - min_val) * contrast, 0, 255).astype(np.uint8) + def show_header_tab(self): + """ + Switches to the Header tab in the tab widget. + """ + self.tab_widget.setCurrentWidget(self.header_tab) - # Convert the adjusted image to QImage and then to QPixmap - q_image = self.convert_to_qimage(adjusted_image) - pixmap = QPixmap.fromImage(q_image) + def open_fits_image(self): + file_name, _ = QFileDialog.getOpenFileName(self, "Open FITS File", "", "FITS Files (*.fits)") + if file_name: + self.open_fits_image(file_name) - # Display the adjusted image - self.display_image(pixmap) + def open_fits_directory(self): + directory = QFileDialog.getExistingDirectory(self, "Open Directory") + if directory: + self.open_fits_directory(directory) diff --git a/image_utils.py b/image_utils.py new file mode 100644 index 0000000..c40bb07 --- /dev/null +++ b/image_utils.py @@ -0,0 +1,32 @@ +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 +