Code source de palm_tracer.UI.GraphViewerWidget

"""
Module contenant la classe :class:`GraphViewerWidget` pour la visualisation interactive
des graphiques liés aux données PALMTracer (pile TIFF, localisations, tracking).

Ce widget fournit :
- Une interface en deux parties :
  • Colonne gauche : informations fichier + présence localisation/tracking, choix du domaine
	(Stack / Localization / Tracking) via 3 boutons exclusifs, et sélection de la source.
  • Zone droite : rendu d'un graphe Plotly dans un QWebEngineView (zoom, pan, hover, export).
- Un couplage léger avec :class:`PALMTracer` pour accéder aux fichiers en cours et charger
  automatiquement pile/CSV (localisations/tracking).
- Des exports HTML/PNG/PDF (PNG via capture Qt en fallback, si Kaleido indisponible).

Notes
-----
- Le rendu interactif utilise QtWebEngine (PySide6-Addons / PyQt6-WebEngine / PyQtWebEngine selon binding).
  Si QtWebEngine n'est pas disponible, un fallback texte explicite est affiché.
- Le widget ne copie pas l'objet :class:`PALMTracer` ; il garde une **référence** passée au constructeur.
- Le calcul/formatage des figures est délégué à :class:`palm_tracer.Processing.Grapher`.

.. todo:: Warning si plus de 10 millions de points sur un affichage (avec option se souvenir du choix).
"""

from pathlib import Path
from typing import Any, cast

import numpy as np
import pandas as pd
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication, QButtonGroup, QCheckBox, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QPushButton, QRadioButton, QVBoxLayout

from palm_tracer.PALMTracer import PALMTracer
from palm_tracer.Settings.Groups import Filtering
from palm_tracer.Settings.Types import CheckBox, Combo, FileList, SpinInt
from palm_tracer.Tools import FileIO, Ui
from palm_tracer.UI.BasePlotlyWidget import BasePlotlyWidget

# ==================================================
# region Constantes
# ==================================================
DATA_SRC: dict[str, list] = {
		"Stack":        ["Intensity"],
		"Localization": ["Localizations Count", "X", "Y", "Z", "Integrated Intensity",
						 "Sigma X", "Sigma Y", "Circularity", "Theta", "Surface", "MSE XY", "MSE Z"],
		"Tracking":     ["Length"],
		"No Dual":      ["Localizations Count", "Length", "MSD"],
		}

TIPS = {
		"Add Stack":   "Add a stack to the batch and load the latest results for it.\n"
					   "Please note that if you are coming from the main widget, the batch will be updated because the settings are linked.",

		"Dual Source": "Allow second source for Graph in scatter plot source A by source B.",
		"Source":      "Data selected for Graph.",

		"MSD Step":    "Step selected for display.",
		"Log":         "Apply a logarithmic scale to the data.",
		"Cumul":       "Show cumulative histogram instead of simple histogram.",
		"Limits":      "Limits data to ±3σ around the mean (3-sigma rule).",
		"Sigma":       "Plots dotted lines at distances of 1, 2, and 3 sigma from the mean.",
		"Gauss":       "Displays the Gaussian curve associated with the mean and standard deviation of the data.",
		"KDE":         "Displays the kernel density estimation (the curve closest to the histogram) associated with the data.",
		"Density":     "The data on Y is expressed in terms of density.",
		"Count":       "The data on Y is expressed in terms of count.",

		"Actualize":   "Updates files/data from PALMTracer status.",
		"Export":      "Opens a dialog box and exports the figure according to the selected extension.",
		}


# ==================================================
# endregion Constantes
# ==================================================

##################################################
[docs] class GraphViewerWidget(BasePlotlyWidget): """Widget de visualisation interactive (Plotly + QtWebEngine) pour PALMTracer. Ce widget expose une UI compacte pour : - afficher des graphes à partir de la pile TIFF (Stack) ou des CSV (Localization/Tracking), - choisir la *famille* de données (Stack / Localization / Tracking) via 3 boutons exclusifs, - sélectionner la *source* dans une ComboBox (ex. Intensité, Localizations Count, etc.), - exporter la figure (HTML/PNG/PDF). Attributs : - _pt (:class:`PALMTracer <palm_tracer.PALMTracer>`) : Référence vers l'instance principale de PALMTracer (aucune copie). - _fig (:class:`Optional[go.Figure]`) : Dernière figure Plotly produite (pour export/maj). - _html (:class:`Optional[str]`) : Dernier HTML généré pour la figure (export .html). - _grapher (:class:`Grapher <palm_tracer.Processing.Grapher>`) : Utilitaire de création de figures (histogrammes, scatter, etc.). - _file (:class:`str`) : Chemin du fichier image courant (TIF). - _stack (:class:`numpy.ndarray`) : Pile d'images (chargée depuis `_file`). - _df (:class:`pandas.DataFrame`) : Dictionnaires de dataframe. Remarques : - Les boutons de domaine "Localization"/"Tracking" sont automatiquement désactivés si aucune donnée correspondante n'est trouvée (cf. :meth:`_refresh_source_buttons`). - L'export PNG utilise un fallback par capture du widget Qt si Kaleido n'est pas utilisé. """ # ================================================== # region Initialisation # ================================================== ################################################## def __init__(self, palmtracer: PALMTracer | None = None): """ Initialise le widget (UI, connexions, état initial) et lie PALMTracer. :param palmtracer: Instance principale :class:`PALMTracer <palm_tracer.PALMTracer>` sans copie (référence partagée). """ super().__init__() self.setWindowTitle("Graph Viewer") # Initialisation des membres self._pt = PALMTracer() if palmtracer is None else palmtracer self._file: str = "" self._density: bool = False self._stack: np.ndarray = np.empty(0) self._df = {"Localization": pd.DataFrame(), "Tracking": pd.DataFrame(), "MSD": pd.DataFrame(), "Instant D": pd.DataFrame(), "Fit": pd.DataFrame()} self._is_updating = True # Construction UI self._init_ui() self._connect_signals() # Tracé initial self._actualize() ################################################## def _init_ui(self): """ Construit l'interface utilisateur : - Colonne gauche : - Informations : Nom du fichier, présence Localizations/Tracking. - Domaine : 3 boutons exclusifs (Stack/Localization/Tracking). - Source : ComboBox dépendante du domaine sélectionné. - Filtres : Section réservée (non implémentée). - Actions : Actualize files / Export… - Zone droite : - QWebEngineView hébergeant la figure Plotly (ou fallback texte si indisponible). """ main_layout = QHBoxLayout(self) Ui.init_layout(main_layout) # Colonne gauche left = QFrame(self) left.setFrameShape(QFrame.Shape.StyledPanel) left.setMinimumWidth(300) vbox = QVBoxLayout(left) Ui.init_layout(vbox) # Boutton pour charger une stack self._btn_add_stack = QPushButton("Add Stack") self._btn_add_stack.setToolTip(TIPS["Add Stack"]) # Bloc Infos (lecture seule) grp_infos, self._status = Ui.make_file_info_group() # Bloc Source (donnée) + Type de graphe grp_source = QGroupBox("Source") h, self._btg_src, self._btn_src = Ui.make_exclusive_btn_group(["Stack", "Localization", "Tracks"], 0) form = Ui.make_form(grp_source) form.addRow(h) # Combo box self._cmb_src_a = Combo("Source", TIPS["Source"]) self._cmb_src_a.box.setMinimumWidth(200) self._cmb_src_a.attach_to_form(form) self._msd_step = SpinInt("MSD Step", TIPS["MSD Step"], 1, [1, 10000], 1) self._msd_step.attach_to_form(form) self._msd_step.hide() self._dual_source = CheckBox("Dual Source", TIPS["Dual Source"]) self._dual_source.attach_to_form(form) self._cmb_src_b = Combo("Source", TIPS["Source"]) self._cmb_src_b.attach_to_form(form) self._cmb_src_b.box.setMinimumWidth(200) self._cmb_src_b.hide() # Bloc Affichage (2 colonnes) grp_display = QGroupBox("Display") grid = QGridLayout(grp_display) self._display_settings: dict[str, Any] = { "Cumul": QCheckBox("Cumulative Histogram"), "Log": QCheckBox("Use Log Scale"), "Limits": QCheckBox("Apply Limits"), "Sigma": QCheckBox("Show σ"), "Gauss": QCheckBox("Show Gaussian"), "KDE": QCheckBox("Show KDE"), "Density": QRadioButton("Density"), "Count": QRadioButton("Count"), "Y Scale": QButtonGroup(), } # Autres options self._display_settings["Log"].setChecked(False) self._display_settings["Cumul"].setChecked(False) self._display_settings["Limits"].setChecked(True) self._display_settings["Sigma"].setChecked(False) self._display_settings["Gauss"].setChecked(False) self._display_settings["KDE"].setChecked(False) self._display_settings["Density"].setChecked(True) # Sélecteur d'échelle Y : Densité / Comptes self._display_settings["Y Scale"].addButton(self._display_settings["Density"]) self._display_settings["Y Scale"].addButton(self._display_settings["Count"]) for key in list(self._display_settings)[:-1]: self._display_settings[key].setToolTip(TIPS[key]) # Placement 2 colonnes grid.addWidget(self._display_settings["Limits"], 0, 0) grid.addWidget(self._display_settings["Sigma"], 0, 1) grid.addWidget(self._display_settings["Gauss"], 1, 0) grid.addWidget(self._display_settings["KDE"], 1, 1) grid.addWidget(self._display_settings["Density"], 2, 0) grid.addWidget(self._display_settings["Count"], 2, 1) grid.addWidget(self._display_settings["Cumul"], 3, 0) grid.addWidget(self._display_settings["Log"], 3, 1) # Bloc Filtres grp_filters, vbox_filters = Ui.make_group(self, "Filters") # Integration des Filtres self._filters = Filtering() self._filters.update_from_dict(self._pt.settings.filtering.to_dict()) vbox_filters.addWidget(self._filters.widget) # Masquage initial self._filters["Save"].hide() self._filters["Localization"].remove_header() self._filters["Tracks"].remove_header() self._filters["Localization"].hide() self._filters["Tracks"].hide() # Actions actions_row = QHBoxLayout() self._btn_actualize = QPushButton("Actualize files") self._btn_actualize.setToolTip(TIPS["Actualize"]) self._btn_export = QPushButton("Export figure") self._btn_export.setToolTip(TIPS["Export"]) actions_row.addStretch(1) actions_row.addWidget(self._btn_actualize) actions_row.addWidget(self._btn_export) vbox.addWidget(self._btn_add_stack) vbox.addWidget(grp_infos) vbox.addWidget(grp_source) vbox.addWidget(grp_display) vbox.addWidget(grp_filters) vbox.addLayout(actions_row) vbox.addStretch(1) main_layout.addWidget(left) main_layout.addWidget(self._web, stretch=1) ################################################## def _connect_signals(self): """Connecte les signaux UI aux callbacks.""" self._btn_add_stack.clicked.connect(self._add_stack) # Sources self._btg_src.idClicked.connect(self._on_source_changed) self._cmb_src_a.connect(self._on_source_cmb_changed) self._cmb_src_b.connect(self._on_source_cmb_changed) self._dual_source.connect(self._on_dual_source_changed) # Display Options Connexion for _, setting in self._display_settings.items(): if isinstance(setting, QCheckBox): setting.stateChanged.connect(self._update_plot) elif isinstance(setting, QButtonGroup): setting.idClicked.connect(self._update_plot) elif isinstance(setting, SpinInt): setting.connect(self._update_plot) # Filters self._filters.buttons["reset"].clicked.connect(self._reset_filtered) self._filters.buttons["update"].clicked.connect(self._update_filtered) self._filters.buttons["save"].clicked.connect(self._pt.save_filtered) self._filters.connect(self._update_plot) # Action Row self._btn_actualize.clicked.connect(self._actualize) self._btn_export.clicked.connect(self._on_export) # ================================================== # endregion Initialisation # ================================================== # ================================================== # region UI Callback # ================================================== ################################################## def _refresh_source_buttons(self) -> None: """ Active/désactive les boutons de domaine selon la disponibilité des données. Si le bouton actif devient indisponible (ex. pas de localisation), bascule automatiquement sur "Stack". """ self._update_df() self._btn_src["Localization"].setEnabled(not self._df["Localization"].empty) self._btn_src["Tracks"].setEnabled(not self._df["Tracking"].empty) # si un bouton désactivé était sélectionné, repasse sur Stack if self._btn_src["Localization"].isChecked() and self._df["Localization"].empty: self._btn_src["Stack"].setChecked(True) if self._btn_src["Tracks"].isChecked() and self._df["Tracking"].empty: self._btn_src["Stack"].setChecked(True) ################################################## def _on_source_changed(self, btn_id: int) -> None: """ Mets à jour la liste des sources selon le domaine choisi puis redessine. :param btn_id: Identifiant du bouton domaine sélectionné (0=Stack, 1=Localization, 2=Tracking). """ self._is_updating = True # Remplir la ComboBox 'Source' en fonction du domaine if btn_id == 0: src = DATA_SRC["Stack"] # . Stack elif btn_id == 1: src = DATA_SRC["Localization"] # Localization else: src = self._get_tracks_src() # . Tracking if self._dual_source.value: src = [s for s in src if s not in DATA_SRC["No Dual"]] with self._cmb_src_a.signal_blocked(): self._cmb_src_a.update_box(src) with self._cmb_src_b.signal_blocked(): self._cmb_src_b.update_box(src) self._update_filters_ui() # . Mise à jour des filtres à afficher self._is_updating = False self._update_plot() # . Puis redessiner le graphe si besoin ################################################## def _on_source_cmb_changed(self) -> None: """Mets à jour les filtres et l'affichage lors du changement de la variable d'intérêt.""" # Affichage de l'option lors de la selection MSD pour choisir le Step et faire Histogram par ce Step if self._btg_src.checkedId() == 2 and self._cmb_src_a.current_text == "MSD": self._msd_step.show() else: self._msd_step.hide() self._update_plot() # Puis redessiner le graphe si besoin ################################################## def _on_dual_source_changed(self, status: bool) -> None: """Affiche/Masque la seconde source.""" self._cmb_src_b.show() if status else self._cmb_src_b.hide() self._on_source_changed(self._btg_src.checkedId()) ################################################## def _update_filters_ui(self): """ Mets à jour les filtres à afficher. Selon la source, les filtres ne seront pas les mêmes (pour ne pas surcharger l'interface de filtres inutiles). """ src_id = self._btg_src.checkedId() if src_id == 0: # Stack self._filters["Localization"].hide() self._filters["Tracks"].hide() elif src_id == 1: # Localisation self._filters["Localization"].show() self._filters["Tracks"].hide() else: # Tracking self._filters["Localization"].hide() self._filters["Tracks"].show() ################################################## def _get_tracks_src(self) -> list[str]: """ Génère la liste des variables d'intérêt pour les Trajectoires en fonction des fichiers disponibles. :return: La liste des sources disponibles pour les trajectoires. """ res = list(DATA_SRC["Tracking"]) if not self._df["MSD"].empty: res += ["MSD"] if not self._df["Instant D"].empty: res += ["Instant D"] if not self._df["Fit"].empty: res += self._df["Fit"].columns[2:].tolist() return res # ================================================== # endregion UI Callback # ================================================== # ================================================== # region PALMTracer Link # ================================================== ################################################## def _add_stack(self): """Permet le chargement d'une image tif pour bypass le chargement initial en lien avec le wiget principal.""" cast(FileList, self._pt.settings.batch["Files"]).add_file() self._pt.load() # . Chargement des derniers résultats self._actualize() # Actualisation des statuts et dataframes internes. ################################################## def _update_df(self): """Récupère les dataframes dans l'objet PALMTracer et mets à jour les status.""" # Récupération des clés loc_key = self._pt.get_localization_key() trc_key = self._pt.get_tracks_key() tc_key = self._pt.get_tracks_compute_key() # Mise à jour des Dataframe self._df["Localization"] = self._pt.df[loc_key] self._df["Tracking"] = self._pt.df[trc_key] self._df["MSD"] = self._pt.df[tc_key[0]] self._df["Instant D"] = self._pt.df[tc_key[1]] self._df["Fit"] = self._pt.df[tc_key[2]] # Mise à jour des Status status = self._pt.get_status() for key in status: self._status[key].setText(status[key]) ################################################## def _actualize(self): """ Actualise les fichiers/données depuis l'état PALMTracer : - Lit le TIF sélectionné (pile `_stack`) pour l'affichage Stack. - Mets à jour les libellés d'information et l'état d'activation des boutons de domaine. - Sélectionne par défaut le domaine "Stack" et redessine. En cas d'erreur de lecture, logue l'erreur via :func:`palm_tracer.Tools.Ui.print_error`. """ self._is_updating = True with self._filters.signal_blocked(), self._pt.settings.signal_blocked(): self._filters.update_from_dict(self._pt.settings.filtering.to_dict()) # Métadonnées d'information self._file = (cast(FileList, self._pt.settings.batch["Files"]).get_selected()) if self._file != "": try: self._stack = FileIO.open_tif(self._file) except Exception as e: Ui.print_error(f"Error loading {self._file} in GraphViewer : {e}") z, y, x = self._stack.shape self._filters.update_limits(x, y, z) self._status["File"].setText(Path(self._file).name if self._file else "No File") self._refresh_source_buttons() # Applique has_loc/has_track self._on_source_changed(0) # . Change la source pour Stack ################################################## def _reset_filtered(self): """Supprime les dataframes de filtre.""" self._is_updating = True with self._filters.signal_blocked(), self._pt.settings.signal_blocked(): self._pt.reset_filtered() # Nettoyage des dataframes filtrés self._filters.reset() self._update_df() # . Récupération des bons dataframe self._is_updating = False self._update_plot() # . Puis redessiner le graphe si besoin ################################################## def _update_filtered(self): """Applique les filtres sur les dataframes.""" self._is_updating = True with self._filters.signal_blocked(), self._pt.settings.signal_blocked(): self._pt.settings.filtering.update_from_dict(self._filters.to_dict()) self._pt.update_filtered() # Mise à jour des filtres self._update_df() # . Récupération des bons dataframe self._is_updating = False self._update_plot() # . Puis redessiner le graphe si besoin # ================================================== # endregion PALMTracer Link # ================================================== # ================================================== # region Drawing # ================================================== ################################################## def _update_plot(self): """Construit la figure Plotly courante en fonction du domaine et de la source.""" if self._is_updating: return src_id = self._btg_src.checkedId() src_a = self._cmb_src_a.current_text src_b = self._cmb_src_b.current_text dual = self._dual_source.value limit = self._display_settings["Limits"].checkState() == Qt.CheckState.Checked sigma = self._display_settings["Sigma"].checkState() == Qt.CheckState.Checked kde = self._display_settings["KDE"].checkState() == Qt.CheckState.Checked gauss = self._display_settings["Gauss"].checkState() == Qt.CheckState.Checked density = self._display_settings["Density"].isChecked() cumul = self._display_settings["Cumul"].isChecked() # Préparation des Données data, title = self._get_plot_data() # print(f"{data.shape}, {data.size}, {title}") with data.size over 10M make a warning box # Selection du graphique à afficher if src_id == 1 and src_a == "Localizations Count": self._fig = self._grapher.scatter(data, title, xlabel="Plane", ylabel="Count", limit=limit, show_sigma=sigma) elif src_id == 2 and src_a == "Length": self._fig = self._grapher.scatter(data, title, xlabel="Track", ylabel="Length", limit=limit, show_sigma=sigma) elif dual: self._fig = self._grapher.cloud(data, title, xlabel=src_a, ylabel=src_b, limit=limit, show_sigma=sigma, kde=kde, gaussian=gauss) else: self._fig = self._grapher.histogram(data, title, limit=limit, show_sigma=sigma, kde=kde, gaussian=gauss, density=density, cumulative=cumul) self._update_web_widget() ################################################## def _get_plot_data(self) -> tuple[np.ndarray, str]: """Récupère et prépare les données pour l'affichage.""" src_id = self._btg_src.checkedId() src_a = self._cmb_src_a.current_text src_b = self._cmb_src_b.current_text dual = self._dual_source.value log_scale = self._display_settings["Log"].isChecked() d, t = self._get_plot_data_from_source(src_id, src_a, log_scale) if dual: t += f" / {src_b}" d_b, _ = self._get_plot_data_from_source(src_id, src_b, log_scale) if d.ndim == 2: d = d[:, 1] if d_b.ndim == 2: d_b = d_b[:, 1] if d_b.size != d.size: return np.empty(0), t d = np.column_stack((d, d_b)) return d, t ################################################## def _get_plot_data_from_source(self, src_id, src, log_scale) -> tuple[np.ndarray, str]: """Récupère et prépare les données pour l'affichage.""" # Stack if src_id == 0: tmp = self._stack # Filtrage par PLan, si sélectionné if self._filters["Plane"].active: limits = self._filters["Plane"].value tmp = tmp[max(limits[0] - 1, 0):min(limits[1], int(tmp.shape[0])), ...] return self._log_data(tmp, log_scale), f"Stack {src}" # Localizations elif src_id == 1: if src == "Localizations Count": s = self._df["Localization"]["Plane"].astype(np.int64) if s.empty: return np.empty(0), src else: planes = np.arange(int(s.min()), int(s.max()) + 1, dtype=int) # Récupération des plans du min au max (si plans vides, ils seront compris) counts = (s.groupby(s).size().reindex(pd.Index(planes), fill_value=0).to_numpy(dtype=int)) # Comptage par groupe return np.column_stack((planes, counts)), src else: s = self._df["Localization"].get(src) # None si la colonne n'existe pas if s is None: return np.empty(0), f"Localizations {src}" return self._log_data(s.to_numpy(dtype=float), log_scale), f"Localizations {src}" # Tracks else: if src == "Length": # Cas particulier, il est peut-être dans le tableau Fit, mais on va utiliser le tableau Tracks initial. group = self._df["Tracking"].groupby("Track")["Plane"].agg(["min", "max"]) # Groupement par track + calcul min et max group["delta"] = group["max"] - group["min"] # . Calcul du delta res = np.column_stack((group.index.to_numpy(), group["delta"].to_numpy())) # Conversion vers numpy 2D : colonne Track + delta return res, f"Tracks {src}" elif src == "MSD": res = self._df["MSD"] step = self._msd_step.value # . Récupération du numéro du Step. col = f"Step {step}" # . Récupération du nom de la colonne. if not {"Track", col}.issubset(res.columns): return np.empty(0), f"Tracks MSD Step {step}" # Vérification de présence des colonnes track, values = res["Track"].astype(int).to_numpy(), res[col].astype(float).to_numpy() # . Séparation track et valeur res = np.column_stack((track, self._log_data(values, log_scale))) # . Application du log sur les valeurs return res[np.isfinite(res).all(axis=1)], f"Tracks MSD Step {step}" # . Retour avec filtrage des Lignes NaN elif src == "Instant D": res = self._df["Instant D"].drop(columns=["Track"], errors="ignore").to_numpy().ravel() # . Récupération des colonnes res = self._log_data(res, log_scale) # . Application du log sur les valeurs return res[np.isfinite(res)], f"Tracks {src}" # . Retour avec filtrage des Lignes NaN else: res = self._df["Fit"] if not {"Track", src}.issubset(res.columns): return np.empty(0), f"Tracks {src}" # . Vérification de présence des colonnes track, values = res["Track"].astype(int).to_numpy(), res[src].astype(float).to_numpy() # . Séparation track et valeur res = np.column_stack((track, self._log_data(values, log_scale))) # . Application du log sur les valeurs return res[np.isfinite(res).all(axis=1)], f"Tracks {src}" # . Retour avec filtrage des Lignes NaN ################################################## @staticmethod def _log_data(data: np.ndarray, log: bool) -> np.ndarray: """ Application du log avec suppression du warning pour les valeurs ≤ 0 et remplacement par Nan de ces valeurs. :param data: Données à transformer :param log: Application du log ou non :return: Données transformées """ with np.errstate(divide='ignore', invalid='ignore'): return np.where(data > 0, np.log10(data), np.nan) if log else data
################################################## if __name__ == "__main__": import sys app = QApplication(sys.argv) w = GraphViewerWidget() w.resize(1280, 720) w.show() sys.exit(app.exec_())