"""
User-Interface (UI) Utilities Module for PyCAT
This module provides functions for displaying dataframes in a pop-up dialog window and adding images with a default
colormap to a Napari viewer. The functions are designed to enhance the user experience when working with dataframes
and image data in the Napari viewer. The module also includes a function to refresh the viewer with new data, as
sometimes modifying an active layer in napari requires manually 'refreshing' it to see the changes.
Author
------
Christian Neureuter, GitHub: https://github.com/cneureuter
Date
----
4-20-2024
"""
# Third party imports
import pandas as pd
import napari
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QScrollArea, QWidget, QLabel, QTableView,
QPushButton, QMenu, QFileDialog, QApplication, QAbstractItemView)
from PyQt5.QtCore import Qt, QAbstractTableModel
[docs]
class DataFrameModel(QAbstractTableModel):
"""
A custom table model to interface with a pandas DataFrame for use within Qt's Model-View-Controller (MVC) architecture.
Attributes
----------
_data : pandas.DataFrame
The pandas DataFrame that backs this model.
Parameters
----------
df : pandas.DataFrame, optional
The pandas DataFrame to be used by the model. Defaults to an empty DataFrame.
"""
[docs]
def __init__(self, df=pd.DataFrame()):
"""
Initializes the DataFrameModel with a specified pandas DataFrame.
Parameters
----------
df : pandas.DataFrame, optional
The pandas DataFrame to initialize the model. Defaults to an empty DataFrame.
"""
super(DataFrameModel, self).__init__()
self._data = df
[docs]
def rowCount(self, parent=None):
"""
Returns the number of rows in the model. Overrides QAbstractTableModel.rowCount.
"""
return self._data.shape[0]
[docs]
def columnCount(self, parent=None):
"""
Returns the number of columns in the model. Overrides QAbstractTableModel.columnCount.
"""
return self._data.shape[1]
[docs]
def data(self, index, role=Qt.DisplayRole):
"""
Returns the data stored at the specified index with the given role. Overrides QAbstractTableModel.data.
Parameters
----------
index : QModelIndex
The index of the data to return.
role : int
The role for which data is requested, typically Qt.DisplayRole for displaying text.
Returns
-------
str or None
The data at the given index as a string if the index is valid and the role is DisplayRole, otherwise None.
"""
if index.isValid() and role == Qt.DisplayRole:
return str(self._data.iloc[index.row(), index.column()])
return None
[docs]
def create_table_view(dataframe):
"""
Creates and configures a QTableView to display a pandas DataFrame within a the Napari viewer's Qt application
using the MVC (Model-View-Controller) design pattern. The function sets up a table model for the DataFrame,
applies settings to optimize the display and interaction, and incorporates a context menu for additional
functionalities such as copying and saving data.
Parameters
----------
dataframe : pandas.DataFrame
The pandas DataFrame to be displayed in the QTableView. This DataFrame is used to populate the rows and
columns of the table view.
Returns
-------
table_view : QTableView
The configured QTableView object with the DataFrame model set, columns resized to fit content, and a custom
context menu enabled for user interactions like copying data to the clipboard or saving it to a CSV file.
Notes
-----
A custom context menu is added to provide additional functionalities directly accessible by right-clicking on the
table view. The context menu is configured to work with the specific position and data of the clicked view through
a lambda function connection.
"""
# Create a table from the DataFrame
table_model = DataFrameModel(dataframe)
table_view = QTableView()
table_view.setModel(table_model)
table_view.resizeColumnsToContents()
table_view.setEditTriggers(QAbstractItemView.NoEditTriggers)
# Context Menu for Copy/Save functionality
table_view.setContextMenuPolicy(Qt.CustomContextMenu)
table_view.customContextMenuRequested.connect(lambda pos: table_context_menu(pos, table_view, dataframe))
return table_view
[docs]
def copy_table_content(table_view):
"""
Copies the content of the specified table view to the clipboard in a tab-separated format, including column headers.
Parameters
----------
table_view : QTableView
The table view whose content is to be copied to the clipboard.
Notes
-----
The function constructs a string of tab-separated values from the table's data, including headers, and copies
it to the system clipboard.
"""
model = table_view.model()
rows = model.rowCount()
cols = model.columnCount()
copied_text = ""
# Adding headers
headers = [model.headerData(i, Qt.Horizontal, Qt.DisplayRole) for i in range(cols)]
copied_text += '\t'.join(headers) + '\n'
for row in range(rows):
# Check if the DataFrame's index is meaningful or just a default range
if isinstance(model._data.index, pd.RangeIndex):
# If it's a default RangeIndex, copy data without the index label
row_data = [model.data(model.index(row, col), Qt.DisplayRole) for col in range(cols)]
else:
# If the index has meaningful labels, prepend the index label to row data
index_label = model.headerData(row, Qt.Vertical, Qt.DisplayRole)
row_data = [index_label] + [model.data(model.index(row, col), Qt.DisplayRole) for col in range(cols)]
copied_text += '\t'.join(row_data) + '\n'
clipboard = QApplication.clipboard()
clipboard.setText(copied_text)
[docs]
def save_table_as_csv(dataframe):
"""
Prompts the user to select a file path and saves the specified pandas DataFrame to a CSV file at that location.
Parameters
----------
dataframe : pandas.DataFrame
The DataFrame to be saved as a CSV file.
Notes
-----
The function opens a file dialog for the user to select the save location and filename, then writes the DataFrame
to a CSV file at the specified path.
"""
path, _ = QFileDialog.getSaveFileName(None, "Save File", "", "CSV Files (*.csv)")
if path:
dataframe.to_csv(path, index=True)
[docs]
def show_dataframes_dialog(window_title, tables_info):
"""
Displays a dialog window with a scrollable area that contains multiple dataframes shown as table views.
Parameters
----------
window_title : str
The title of the dialog window.
tables_info : list of tuples
A list where each tuple contains a title (str) for the table and a pandas DataFrame. The title is displayed
as a label above the corresponding table view.
Notes
-----
Each DataFrame from the `tables_info` list is displayed in a separate section within a scrollable area.
The dialog includes an "OK" button to close the window.
"""
# Create a dialog window
dialog = QDialog()
dialog.setWindowTitle(window_title)
layout = QVBoxLayout()
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scrollContent = QWidget(scroll)
scrollLayout = QVBoxLayout(scrollContent)
scrollContent.setLayout(scrollLayout)
# Add each table to the scroll area
for title, df in tables_info:
if df is not None:
section_label = QLabel(title)
section_label.setStyleSheet("font-size: 16pt;") # Adjust the font size as needed
scrollLayout.addWidget(section_label, alignment=Qt.AlignCenter) # Align the title to the center
table_view = create_table_view(df)
scrollLayout.addWidget(table_view)
# Add the scroll area and an OK button to the dialog
scroll.setWidget(scrollContent)
layout.addWidget(scroll)
button = QPushButton("OK")
button.clicked.connect(dialog.accept)
layout.addWidget(button)
dialog.setLayout(layout)
dialog.exec_()
[docs]
def add_image_with_default_colormap(data, viewer, colormap='viridis', **kwargs):
"""
Adds an image to a Napari viewer using a specified colormap, with an emphasis on 'viridis' for enhanced visual
inspection. This function is tailored for use with the Napari visualization tool, facilitating the addition of
image data with a specified colormap for improved visual distinction and analysis.
Parameters
----------
data : numpy.ndarray
The image data to be displayed in the Napari viewer. Compatible with the types of data Napari can visualize.
viewer : napari.Viewer
An instance of the Napari Viewer that supports the `add_image` method.
colormap : str, optional
The name of the colormap to apply to the image data, defaulting to 'viridis' for its effective visual clarity.
**kwargs : dict
Additional keyword arguments to be passed to the `add_image` method of the Napari viewer, allowing for
customization such as opacity, blending mode, scale, and more.
Notes
-----
The default colormap 'viridis' is chosen due to its effectiveness in making distinct features stand out visually.
Other colormaps provided by Napari can also be specified for different visual effects.
"""
# Add the image to the Napari viewer with the specified (or default viridis) colormap and any additional kwargs
viewer.add_image(data, colormap=colormap, **kwargs)
[docs]
def refresh_viewer_with_new_data(viewer, active_layer, new_data=None):
"""
Update the Napari viewer by removing an active label layer and replacing it with a new layer that
contains updated data. This function is designed to refresh the display after data modifications
and can be used with either the current data or newly provided data.
Parameters
----------
viewer : napari.Viewer
The Napari viewer instance in which the layer is displayed.
active_layer : napari.layers.Layer
The currently active label layer that needs to be refreshed. This layer will be replaced.
new_data : numpy.ndarray, optional
The new data to be displayed in the layer. If None, the function uses a copy of the current
data in `active_layer`. Defaults to None.
Raises
------
ValueError
If the active layer type is not supported for refreshing the viewer.
Notes
-----
The function preserves the name of the active layer, removes the old layer from the viewer, and
adds a new layer with the same name but updated data. If `new_data` is provided, it replaces
the current data; otherwise, a copy of the current data is used to refresh the viewer.
"""
if new_data is None:
updated_data = active_layer.data.copy() # Create a copy of the updated data
else:
updated_data = new_data.copy() # Use the provided new data
layer_name = active_layer.name # Preserve the name of the active layer
if isinstance(active_layer, napari.layers.Image):
viewer.layers.remove(active_layer)
add_image_with_default_colormap(viewer, updated_data, name=layer_name)
elif isinstance(active_layer, napari.layers.Labels):
viewer.layers.remove(active_layer) # Remove the old layer
viewer.add_labels(updated_data, name=layer_name) # Re-add the layer with the updated data
else:
raise ValueError("The active layer type is not supported for refreshing the viewer.")