""" Module contenant les fonctions de visualisation. """
import numpy as np
import pandas as pd
import seaborn as sns
from matplotlib import pyplot as plt
MAX_UI_16 = np.iinfo(np.uint16).max
MAX_UI_8 = np.iinfo(np.uint8).max
SCALE = MAX_UI_16 / 8 # Échelle cible de normalization (permet une résolution de superposition de points de 8 fois)
##################################################
[docs]
def normalize_data(data: np.ndarray, scale: int = SCALE) -> np.ndarray:
"""
Normalisation des données avec prise en compte de l'ordre de grandeur et adaptation des plages.
Règles :
- Si toutes les valeurs sont dans [0,1], normalisation vers [0, SCALE].
- Si valeurs négatives et positives, on prend la puissance de 2 la plus proche de max(abs(min), abs(max)) et on transpose vers [0, SCALE].
- Colonne uniforme : on force une valeur constante de SCALE.
- Si toutes les valeurs sont positives, on considère 0 comme min et on normalise avec la puissance de 2 la plus proche du max.
:param data: Données à normaliser.
:param scale: échelle de normalisation
:return: Données normalisées.
"""
if data is None or data.size == 0: return np.zeros_like(data)
min_val, max_val = data.min(), data.max()
# Cas 1 : Colonne uniforme (toutes les valeurs identiques)
if min_val == max_val: return np.full_like(data, scale)
# Cas 2 : Valeurs entre 0 et 1
if min_val >= 0 and max_val <= 1: return scale * data
# Cas 3 : Valeurs négatives et positives -> on centre autour de 0
if min_val < 0 < max_val:
bound = 2 ** np.ceil(np.log2(max(abs(min_val), abs(max_val))))
return (scale / (2 * bound)) * (data + bound)
# Cas 4 : Valeurs positives -> on prend 0 comme min et on ajuste avec la puissance de 2 la plus proche du max
bound = 2 ** np.ceil(np.log2(max_val))
return (scale / bound) * data
##################################################
[docs]
def get_bins_number(data: np.ndarray, limits=(30, 300)) -> int:
"""
Calcule un nombre de bin adaptatif pour un histogramme.
:param data: données à analyser
:param limits: bornes pour le nombre de bins.
:return: nombre de bins.
"""
n_values = len(data)
# bins = int(np.sqrt(n_values)) # Règle de racine carrée
bins = int(np.ceil(np.log2(n_values) + 1)) # Règle de Sturges
return max(limits[0], min(bins, limits[1])) # Bornes pour éviter des valeurs extrêmes
##################################################
[docs]
def render_hr_image(width: int, height: int, ratio: int, points: np.ndarray, normalization: bool = True) -> np.ndarray:
"""
Construit une image Haute résolution en fonction des éléments localisés.
:param width: Largeur de l'image.
:param height: Hauteur de l'image.
:param ratio: Ratio d'aggrandissement de l'image.
:param points: Localisations des points.
:param normalization: Normalisation des valeurs (pour les mettre entre 0 et SCALE).
:return: Nouvelle image en uint16.
"""
if ratio < 1: return np.zeros((height, width), dtype=np.uint16)
if width < 1 or height < 1: return np.zeros((max(int(height * ratio), 1), max(int(width * ratio), 1)), dtype=np.uint16)
res = np.zeros((int(height * ratio), int(width * ratio)), dtype=float)
# Filtrage des points hors des dimensions initiales
mask = (points[:, 0] < width) & (points[:, 1] < height)
points = points[mask]
if points is None or points.size == 0 or points.shape[1] != 3: return res.astype(np.uint16)
# Calcul des nouvelles coordonnées (vectorisé)
coords = np.round(points[:, :2] * ratio).astype(int)
x, y = coords[:, 0], coords[:, 1]
values = normalize_data(points[:, 2]) if normalization else points[:, 2]
np.add.at(res, (y, x), values) # 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 np.asarray(res, dtype=np.uint16) # Forcer le type de l'image en np.uint16
##################################################
[docs]
def render_roi(image: np.ndarray, points: np.ndarray, roi_size: int, color: list[int]) -> np.ndarray:
"""
Construit une image RGB à partir d'une image en niveaux de gris et ajoute des contours de ROIs autour des points donnés.
:param image: Image d'entrée en niveaux de gris (numpy array 2D).
:param points: Tableau 2D des coordonnées (X, Y) des points, sous forme de flottants.
:param roi_size: Taille du carré à dessiner autour de chaque point.
:param color: Couleur du contour du ROI en RGB (tuple ou liste de trois valeurs).
:return: Image RGB avec les contours des ROIs dessinés.
"""
# Normalisation des niveaux de gris sur 0-255
min_val, max_val = image.min(), image.max()
if max_val > min_val: image = ((image - min_val) / (max_val - min_val) * MAX_UI_8).astype(np.uint8)
else: image = np.zeros_like(image, dtype=np.uint8) # Cas d'une image uniforme
# Conversion en RGB
res = np.stack([image] * 3, axis=-1)
if points is None or points.size == 0 or points.shape[1] != 2: return res
# Dessin des contours des ROIs
half_size = (roi_size / 2.0) # Demi taille de la ROI.
max_height, max_width = image.shape[0], image.shape[1]
for x, y in points:
y_min, y_max = max(0, int(round(y - half_size))), min(max_height, int(round(y + half_size)))
x_min, x_max = max(0, int(round(x - half_size))), min(max_width, int(round(x + half_size)))
# Dessiner le contour du carré
res[y_min:y_max, x_min] = color # Ligne gauche
res[y_min:y_max, x_max] = color # Ligne droite
res[y_min, x_min:x_max] = color # Ligne haut
res[y_max, x_min:min(max_width, x_max + 1)] = color # Ligne bas (distance +1 pour avoir un carré "fini")
return res
##################################################
[docs]
def plot_histogram(ax: plt.axes, data: np.ndarray, title: str, limit: bool = True, kde: bool = True, density: bool = True):
"""
Trace un histogramme des données avec Seaborn, et optionnellement une courbe kernel density estimation.
:param ax: Axe sur lequel tracer l'histogramme.
:param data: Données sous forme de tableau numpy.
:param title: Titre de l'histogramme.
:param limit: Si True, applique la règle des 3 sigmas pour limiter les données.
:param kde: Si True, superpose une kde gaussienne.
:param density: Si True, normalise l'histogramme pour afficher une densité de probabilité.
"""
data = data.ravel() # Convertit en un tableau 1D
if len(data) == 0: return
# Ajout d'un style avec Seaborn
sns.set_style("white")
# Limite des données avec la règle des 3 Sigmas
if limit:
mu, sigma = np.mean(data), np.std(data)
if sigma == 0: return
limits = [mu - 3 * sigma, mu + 3 * sigma] # Limite théoriques des datas
data = data[(data >= limits[0]) & (data <= limits[1])] # Suppression des datas au dela des limites
limits = [max(limits[0], min(data)), min(limits[1], max(data))] # On resserre les limites autour des datas
else:
limits = [min(data), max(data)]
# Tracé de l'histogramme avec Seaborn
hist_plot = sns.histplot(data, bins=get_bins_number(data), kde=False, stat="density" if density else "count", ax=ax, alpha=0.75)
if kde:
kde_plot = sns.kdeplot(data, ax=ax, linestyle="--", color=sns.color_palette()[1]) # Prend la deuxième couleur de Seaborn
if not density:
# Adapter le KDE à l'échelle de l'histogramme (calcul du facteur d'ajustement)
max_count = max([patch.get_height() for patch in hist_plot.patches])
kde_scale = max_count / max(kde_plot.get_lines()[0].get_ydata())
kde_plot.get_lines()[0].set_ydata(kde_plot.get_lines()[0].get_ydata() * kde_scale)
ax.set_title(title)
ax.set_xlim(limits)
ax.set_xlabel("Values")
ax.set_ylabel("Density" if density else "Count")
##################################################
[docs]
def plot_plane_violin(ax: plt.axes, data: np.ndarray, title: str):
"""
Trace un graphique type violon pour les données en entrée .
:param ax: Axe sur lequel tracer l'histogramme.
:param data: Données à tracer sous forme de tableau numpy.
:param title: Titre du graphique.
"""
if len(data) == 0: return
sns.set_style("white")
# Conversion des données en DataFrame pour seaborn
df = pd.DataFrame(data, columns=["Plane", "Value"])
sns.violinplot(x="Plane", y="Value", data=df, ax=ax, inner="quartile")
ax.set_title(title)
ax.set_xlabel("Planes")
ax.set_ylabel("Density")
##################################################
[docs]
def plot_plane_heatmap(ax: plt.Axes, data: np.ndarray, title: str, cmap="magma"):
"""
Trace une heatmap montrant la densité des valeurs par plan.
:param ax: Axe sur lequel tracer la heatmap.
:param data: Données sous forme de tableau numpy.
La première colonne représente les plans, la deuxième les valeurs.
:param title: Titre du graphique.
:param cmap: Color Map utilisé pour tracer la heatmap (liste des colormaps : https://matplotlib.org/stable/tutorials/colors/colormaps.html).
"""
if data.shape[0] == 0 or data.shape[1] < 2: return
sns.set_style("white")
# Création d'un histogramme 2D pour la densité des points
planes = data[:, 0].astype(int) # S'assurer que les plans sont bien des entiers
values = data[:, 1]
n_planes = planes.max() + 1
hist, x_edges, y_edges = np.histogram2d(planes, values, bins=[n_planes, get_bins_number(data)])
# Tracé de la heatmap avec pcolormesh
mesh = ax.pcolormesh(x_edges, y_edges, hist.T, shading='auto', cmap=cmap)
# Ajout d'une barre de couleur
plt.colorbar(mesh, ax=ax, label="Densité")
# Paramètres du graphique
ax.set_title(title)
ax.set_xlabel("Planes")
ax.set_ylabel("Values")