Code source de palm_tracer.Processing.Renderer

"""Fichier contenant une classe pour créer des rendus."""

from dataclasses import dataclass, field

import numpy as np
import pandas as pd

MAX_UI_16 = np.iinfo(np.uint16).max


##################################################
[docs] @dataclass class Renderer: """Créateur de graphiques avec Plotly.""" _width: int = field(init=False, default=1) _height: int = field(init=False, default=1) _ratio: int = field(init=False, default=1) ##################################################
[docs] def set_size(self, width: int, height: int, ratio: int): """ Mets à jour les tailles pour le rendu :param width: Largeur de l'image. :param height: Hauteur de l'image. :param ratio: Ratio d'agrandissement de l'image. Les coordonnées sont multipliées par ce facteur. """ self._width, self._height, self._ratio = width, height, ratio
##################################################
[docs] def localizations(self, loc: np.ndarray) -> np.ndarray: """ Construit une image Haute résolution (uint16) en fonction des éléments localisés. :param loc: Position des points à représenter sous forme de tableau 2D de N lignes et 3 colonnes (X, Y, Couleur). :return: Nouvelle image en uint16 de forme (height*ratio, width*ratio). """ # Vérification des dimensions new_h, new_w = int(self._height * self._ratio), int(self._width * self._ratio) if new_h < 1 or new_w < 1: return np.zeros((max(new_h, 1), max(new_w, 1)), dtype=np.uint16) res = np.zeros((new_h, new_w), dtype=float) if loc.ndim != 2 or loc.shape[1] != 3: return res.astype(np.uint16) # Filtrage des points hors des dimensions initiales et retour si aucun n'est disponible mask = ((loc[:, 0] >= 0) & (loc[:, 0] < self._width) & (loc[:, 1] >= 0) & (loc[:, 1] < self._height)) loc = loc[mask] if loc.size == 0: return res.astype(np.uint16) # Calcul des nouvelles coordonnées entières (vectorisé) coords = np.round(loc[:, :2] * self._ratio).astype(int) x, y, colors = coords[:, 0], coords[:, 1], loc[:, 2] # Calcul de l'image finale np.add.at(res, (y, x), colors) # Accumulation des valeurs (plus efficace qu'une boucle) res = res.clip(0, MAX_UI_16) # . Limite les valeurs entre 0 et la valeur maximale possible pour un uint16 return res.astype(np.uint16) # . Forcer le type de l'image en np.uint16
##################################################
[docs] def tracks(self, trc: np.ndarray) -> np.ndarray: """ Construit une image haute résolution (uint16) à partir de trajectoires localisées. Chaque trajectoire est tracée par segments (P0→P1, P1→P2, …). Colonnes attendues dans `tracks` : - "Track" : identifiant de la trajectoire (:class:`int`) - "X", "Y" : coordonnées (:class:`float`, en pixels dans l'image de base) - "Color" : intensité à tracer ``(0..65535)``. Toute valeur hors bornes est tronquée. :param trc: Tableau des points de trajectoires sous forme de tableau 2D de N lignes et 4 colonnes (Track, X, Y, Couleur). :return: Nouvelle image en uint16 de forme (height*ratio, width*ratio). """ # Vérification des dimensions new_h, new_w = int(self._height * self._ratio), int(self._width * self._ratio) if new_h < 1 or new_w < 1: return np.zeros((max(new_h, 1), max(new_w, 1)), dtype=np.uint16) res = np.zeros((new_h, new_w), dtype=np.uint16) if trc.ndim != 2 or trc.shape[1] != 4: return res # Filtrage des points hors des dimensions initiales et retour si aucun n'est disponible mask = ((trc[:, 1] >= 0) & (trc[:, 1] < self._width) & (trc[:, 2] >= 0) & (trc[:, 2] < self._height)) trc = trc[mask] if trc.size == 0: return res # Calcul des nouvelles coordonnées entières (vectorisé) coords = np.round(trc[:, 1:3] * self._ratio).astype(int) trc, x, y, colors = trc[:, 0].astype(int), coords[:, 0], coords[:, 1], trc[:, 3].astype(np.uint16) # Indices de début/fin de chaque groupe Track # tracks[1:] != tracks[:-1] Compare chaque élément au précédent # np.flatnonzero pour avoir les indices des True donc indique le dernier élément de chaque trajectoire # np.r_ concatène des séquences. On ajoute 0 et tracks.size. split_idx = np.r_[0, 1 + np.flatnonzero(trc[1:] != trc[:-1]), trc.size] # Pour chaque trajectoire, couleur unique for g in range(len(split_idx) - 1): start, end = split_idx[g], split_idx[g + 1] # if end - start == 0: continue impossible, on vérifie en amont les dataframe vide pouvant provoquer ce cas if end - start == 1: self.draw_line(res, x[start], y[start], x[start], y[start], colors[start]) # tracer des points isolés else: # tracer segments successifs for i in range(start, end - 1): self.draw_line(res, x[i], y[i], x[i + 1], y[i + 1], colors[i]) return res
# ================================================== # region Tools # ================================================== ##################################################
[docs] @staticmethod def get_localization_colors(loc: pd.DataFrame, col: str = "", max_value: float = 0) -> np.ndarray: """ Construit un tableau numpy contenant les coordonnées des localisations et une valeur scalaire associée à utiliser comme intensité/couleur. Le tableau retourné est de forme ``(N, 3)`` et contient, dans l'ordre : ``X``, ``Y`` et ``Color``. - Les colonnes ``X`` et ``Y`` sont toujours extraites du DataFrame. - La colonne ``Color`` provient de ``col`` si elle existe. - Si ``col`` est absente, la colonne ``Color`` est remplie avec la valeur 1. - Si la valeur minimale de ``Color`` est négative, toutes les valeurs sont décalées afin que le minimum devienne nul. :math:`C_{Shifted} = C - C_{min}` - Si ``max_value > 0``, les valeurs de ``Color`` sont normalisées linéairement dans l'intervalle ``[0, max_value]``. :math:`C_{Norm} = C_{Shifted} \\times \\frac{C}{C_{max}}` La fonction ne modifie pas le DataFrame d'origine. :param loc: DataFrame contenant au minimum les colonnes ``X`` et ``Y``. :param col: Nom de la colonne à utiliser pour calculer la composante ``Color``. :param max_value: Valeur maximale cible pour la normalisation. Si ``max_value <= 0``, aucune normalisation n'est appliquée. :return: Tableau numpy de forme ``(N, 3)`` de type ``float64`` contenant ``X``, ``Y`` et ``Color``. :raises KeyError: Si les colonnes ``X`` ou ``Y`` sont absentes. .. note:: La normalisation n'est appliquée que si le maximum de la colonne ``Color`` après décalage est strictement positif. Cela évite une division par zéro lorsque toutes les valeurs sont nulles. """ if loc.empty: return np.empty((0, 3), dtype=np.float64) # Extraction directe en numpy pour éviter les copies/alignements pandas inutiles. xy = loc.loc[:, ["X", "Y"]].to_numpy(dtype=np.float64, copy=True) if col in loc.columns: colors = loc[col].to_numpy(dtype=np.float64, copy=True) else: colors = np.ones(len(loc), dtype=np.float64) # Post-traitement des couleurs. color_min = np.min(colors) if color_min < 0.0: colors -= color_min # . Décalage pour garantir un minimum nul. color_max = np.max(colors) if color_max <= 0.0: colors = np.ones(len(loc), dtype=np.float64) # Si l'on n'a que des 0, passe tout à 1. elif max_value > 0.0: colors *= max_value / color_max # . Normalisation éventuelle. return np.column_stack((xy, colors))
##################################################
[docs] @staticmethod def get_tracks_colors(trc: pd.DataFrame, source: str = "", max_value: float = 0) -> np.ndarray: """ Construit un tableau numpy contenant les numéros, plans et coordonnées des trajectoires ainsi qu'une valeur scalaire associée à utiliser comme intensité/couleur. Le tableau retourné est de forme ``(N, 5)`` et contient, dans l'ordre : ``Track``, ``Plane``, ``X``, ``Y`` et ``Color``. - Les colonnes ``Track``, ``Plane``, ``X`, ``Y`` et ``Integrated Intensity``sont toujours extraites du DataFrame. - La colonne ``Color`` est défini selon la source, si la source n'est pas prévu, la colonne ``Color`` est remplie avec la valeur 1. - Si la valeur minimale de ``Color`` est négative, toutes les valeurs sont décalées afin que le minimum devienne nul. :math:`C_{Shifted} = C - C_{min}` - Si ``max_value > 0``, les valeurs de ``Color`` sont normalisées linéairement dans l'intervalle ``[0, max_value]``. :math:`C_{Norm} = C_{Shifted} \\times \\frac{C}{C_{max}}` :param trc: DataFrame contenant au minimum les colonnes ``Track``, ``Plane``, ``X``, ``Y`` et ``Integrated Intensity``. :param source: Type de données à utiliser pour calculer la composante ``Color``. :param max_value: Valeur maximale cible pour la normalisation. Si ``max_value <= 0``, aucune normalisation n'est appliquée. :return: Tableau numpy de forme ``(N, 5)`` de type ``float64`` contenant ``Track``, ``Plane``, ``X``, ``Y`` et ``Color``. :raises KeyError: Si les colonnes ``X`` ou ``Y`` sont absentes. .. note:: La normalisation n'est appliquée que si le maximum de la colonne ``Color`` après décalage est strictement positif. Cela évite une division par zéro lorsque toutes les valeurs sont nulles. """ if trc.empty: return np.empty((0, 5), dtype=np.float64) # --- Extraction des données utiles. --- data = trc[["Track", "Plane", "X", "Y", "Integrated Intensity"]].copy() data = data.sort_values(["Track", "Plane"], kind="mergesort") # Tri stable : Track puis Plane puis ordre d'origine. # --- Définition de la couleur selon la source --- # Numéro de la trajectoire. if source == "Track Number": data["Color"] = data["Track"].to_numpy(dtype=np.float64) # Plan de chaque point. elif source == "Plane": data["Color"] = data["Plane"].to_numpy(dtype=np.float64) # Somme des intensités intégrées par trajectoire, recopiée sur tous les points de la trajectoire. elif source == "Intensity": data["Color"] = data.groupby("Track")["Integrated Intensity"].transform("sum").to_numpy(dtype=np.float64) # Longueur totale de la trajectoire elif source == "Length": # somme des distances euclidiennes entre points successifs d'une même trajectoire. dx = data.groupby("Track")["X"].diff().to_numpy(dtype=np.float64) dy = data.groupby("Track")["Y"].diff().to_numpy(dtype=np.float64) # Les premières valeurs de chaque trajectoire valent NaN : elles ne contribuent pas à la longueur. segment_lengths = np.sqrt(np.square(dx) + np.square(dy)) segment_lengths = np.nan_to_num(segment_lengths, nan=0.0) data["SegmentLength"] = segment_lengths data["Color"] = data.groupby("Track")["SegmentLength"].transform("sum").to_numpy(dtype=np.float64) data.drop(columns="SegmentLength", inplace=True) # Durée en nombre de plans couverts par la trajectoire. elif source == "Duration": first_plane = data.groupby("Track")["Plane"].transform("min").to_numpy(dtype=np.float64) last_plane = data.groupby("Track")["Plane"].transform("max").to_numpy(dtype=np.float64) data["Color"] = last_plane - first_plane + 1 # +1 pour etre inclusif # Autre source. else: data["Color"] = np.ones(len(trc), dtype=np.float64) # --- Post-traitement des couleurs. --- color_min = data["Color"].min() if color_min < 0.0: data["Color"] -= color_min # . Décalage pour garantir un minimum nul. color_max = data["Color"].max() if color_max <= 0.0: data["Color"] = np.ones(len(trc), dtype=np.float64) # Si l'on n'a que des 0, passe tout à 1. elif max_value > 0.0: data["Color"] *= max_value / color_max # . Normalisation éventuelle. return data[["Track", "Plane", "X", "Y", "Color"]].to_numpy(dtype=np.float64)
##################################################
[docs] @staticmethod def draw_line(img: np.ndarray, x0: int, y0: int, x1: int, y1: int, color: np.uint16): """ Trace une ligne discrète entre deux points dans une image 2D en utilisant l'algorithme de Bresenham (version entière, sans flottants). La ligne est rasterisée en parcourant les pixels entre les coordonnées (x0, y0) et (x1, y1), avec une gestion robuste de toutes les pentes (horizontales, verticales, diagonales, fortes et faibles). Pour chaque pixel visité, la valeur est mise à jour uniquement si la nouvelle couleur est strictement supérieure à la valeur déjà présente. Cela permet de conserver l'intensité maximale (utile par exemple pour des accumulations de tracés ou des cartes d'intensité). :param img: Image 2D (numpy.ndarray) modifiée *in-place*. Doit être indexable sous la forme ``img[y, x]``. :param x0: Coordonnée X du point de départ. :param y0: Coordonnée Y du point de départ. :param x1: Coordonnée X du point d'arrivée. :param y1: Coordonnée Y du point d'arrivée. :param color: Intensité à écrire dans les pixels traversés. """ h_max, w_max = img.shape[0], img.shape[1] dx, dy = abs(x1 - x0), -abs(y1 - y0) # . Distance maximale sx, sy = 1 if x0 < x1 else -1, 1 if y0 < y1 else -1 # Orientation err = dx + dy # . Erreur accumulée (dy est négatif) while True: if 0 <= x0 < w_max and 0 <= y0 < h_max: # . Vérification des limites de l'image if color > img[y0, x0]: img[y0, x0] = color # Changement de couleur si elle est plus élevé que la couleur courante. if x0 == x1 and y0 == y1: break # . Condition d'arrêt e2 = err << 1 # . 2*err pour décider dans quelle direction avancer. if e2 >= dy: # . On avance en X si l’erreur le permet err += dy x0 += sx if e2 <= dx: # . On avance en Y si nécessaire err += dx y0 += sy