Source code for motila.projection

"""
Projection and plotting helpers for MotilA.

This module contains z-projection utilities and plot/table outputs that describe
projection intensity, histograms, and projected image stacks.

author: Fabrizio Musacchio  
date: September 2023
ported to modular MotilA: August 2026
"""
# %% IMPORTS
import os
from pathlib import Path
import time

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from skimage import exposure

from .export import DEFAULT_TABLE_EXPORT_FORMATS, export_dataframe
from .io import write_image_stack
# %% PROJECTION RANGE HELPERS

[docs] def calc_projection_range(projection_center, projection_layers, I_shape, log): """ Calculate a z-projection range for a given center plane and number of layers, ensuring that the range stays within stack boundaries. Parameters ---------- projection_center : int Index of the central z-plane around which the projection is computed. projection_layers : int Total number of layers to include in the projection (symmetric around ``projection_center``). I_shape : tuple Shape of the input image stack. The second entry must represent the z-dimension. log : object Logging object with a ``log`` method for recording warnings and information. Returns ------- tuple A tuple ``(projection_range, projection_layers)`` where: * **projection_range** : list of int Two-element list ``[start, end]`` defining the z-range after boundary correction. * **projection_layers** : int Actual number of layers used in the projection after adjusting for stack limits. Notes ----- The projection range is clipped automatically if ``projection_center Β± layers/2`` extends beyond stack boundaries. Any correction is reported via ``log.log()``. """ # check if projection_center is out of bounds: if projection_center < 0 or projection_center >= I_shape[1]: log.log(f"WARNING: projection center {projection_center} is out of bounds for image z-dimension {I_shape[1]} -> skipping.") return [0, 0], 0 # No valid projection range and number of layers therefore 0 projection_half = projection_layers // 2 # integer division for symmetry # calculate the projection range: if projection_layers % 2 == 1: # odd number of layers: symmetric range around projection center projection_range = [projection_center - projection_half, projection_center + projection_half] else: # even number of layers: two possible valid projections projection_range = [projection_center - projection_half + 1, projection_center + projection_half] # convert to integer: projection_range = [int(projection_range[0]), int(projection_range[1])] # validate against stack dimensions: projection_layers_correction = 0 z_layers = I_shape[1] # if the projection range exceeds the image boundaries, adjust accordingly: if projection_range[0] < 0: projection_range[0] = 0 log.log(f"WARNING: projection range {projection_range} adjusted as it was below 0.") if projection_range[1] >= z_layers: projection_range[1] = z_layers - 1 log.log(f"WARNING: projection range {projection_range} exceeds image z-dimension {z_layers} -> adjusted.") # calculate the number of layers currently in the range: current_layers = projection_range[1] - projection_range[0] + 1 # adjust the range if there are not enough layers: if current_layers < projection_layers: # expand the range symmetrically, if possible, starting from the center: left_side = projection_range[0] right_side = projection_range[1] # first try expanding to the left: if left_side > 0: projection_range[0] -= 1 # then try expanding to the right if we still have fewer layers: if right_side < z_layers - 1: projection_range[1] += 1 # if necessary, shift the range to fit the exact number of layers: current_layers = projection_range[1] - projection_range[0] + 1 if current_layers < projection_layers: if projection_range[0] > 0: projection_range[0] -= 1 if projection_range[1] < z_layers - 1: projection_range[1] += 1 log.log(f"Projection center: {projection_center}, Projection range: {projection_range}") # update projection_layers if it was adjusted to the actual number of layers: projection_layers = projection_range[1] - projection_range[0] + 1 return projection_range, projection_layers
# %% GENERAL PLOTTING HELPERS
[docs] def plot_2D_image(image, plot_path, plot_title, fignum=1, figsize=(5,5.15), show_ticks=False, show_borders=False, cbar_show=False, cmap=plt.get_cmap('viridis'), cbar_label="", cbar_ticks=[], cbar_ticks_labels="", title=""): """ Plots a 2D image and saves it as a PDF file. Parameters ----------- image : array-like The 2D array representing the image to be plotted. plot_path : str or Path The directory path where the plot will be saved. plot_title : str The filename for the saved plot (without extension). fignum : int, optional The figure number for the plot (default is 1). cmap : matplotlib.colors.Colormap, optional The colormap to be used for the image (default is 'viridis'). cbar_label : str, optional The label for the colorbar (default is an empty string). cbar_ticks : list of float, optional Tick positions for the colorbar (default is an empty list, meaning automatic ticks). cbar_ticks_labels : list of str, optional Labels for the colorbar ticks (default is an empty list, meaning no custom labels). title : str, optional The title of the plot (default is an empty string). Returns -------- None This function saves the plot as a PDF file and does not return a value. Notes ------ - The plot is saved in the specified directory as `<plot_title>.pdf` with a resolution of 500 DPI. - A colorbar is added if `cbar_label` is provided. - The """ #plt.clf() fig = plt.figure(fignum, figsize=figsize) plt.clf() plt.imshow(image, cmap=cmap) if cbar_show: cbar = plt.colorbar(label=cbar_label) if len(cbar_ticks)>0: cbar.set_ticks(cbar_ticks) if len(cbar_ticks_labels)>0: cbar.set_ticklabels(cbar_ticks_labels) if not show_ticks: plt.xticks([]) plt.yticks([]) if not show_borders: plt.gca().spines['top'].set_visible(False) plt.gca().spines['right'].set_visible(False) plt.gca().spines['bottom'].set_visible(False) plt.gca().spines['left'].set_visible(False) plt.title(title) plt.tight_layout() plt.savefig(Path(plot_path, plot_title + ".pdf"), dpi=500) plt.close(fig)
[docs] def plot_2D_image_as_tif(image, plot_path, plot_title): """ Saves a 2D image as an OME-TIFF file. Parameters ----------- image : array-like The 2D array representing the image to be saved. plot_path : str or Path The directory where the image file will be saved. plot_title : str The filename for the saved image file (without extension). Returns -------- None This function saves the image as an image file and does not return a value. Notes ------ - The file is saved as `<plot_title>.tif` in the specified directory. - The image is written through OMIO while preserving the historical `.tif` filename. """ TIFF_path = os.path.join(plot_path, plot_title+".tif") write_image_stack(TIFF_path, image)
[docs] def plot_histogram(image, plot_path, plot_title, fignum=1, title="histogram"): """ Plots the histogram and cumulative distribution function (CDF) of an image and saves it as a PDF file. Parameters ----------- image : array-like The 2D array representing the image for which the histogram is computed. plot_path : str or Path The directory where the histogram plot will be saved. plot_title : str The filename for the saved histogram plot (without extension). fignum : int, optional The figure number for the plot (default is 1). title : str, optional The title of the plot (default is "histogram"). Returns -------- None The function saves the histogram plot as a PDF file and does not return a value. Notes ------ - The function computes the histogram and cumulative distribution function (CDF) using `skimage.exposure`. - The plot is saved as `<plot_title>.pdf` in the specified directory. - The function requires `matplotlib.pyplot` and `skimage.exposure` for plotting. """ fig = plt.figure(fignum) plt.clf() img_hist, bins = exposure.histogram(image, source_range='image') plt.plot(bins, img_hist / img_hist.max()) img_cdf, bins = exposure.cumulative_distribution(image) plt.plot(bins, img_cdf) plt.title(title) plt.tight_layout() plt.savefig(Path(plot_path, plot_title + ".pdf"), dpi=500) plt.close(fig)
[docs] def plot_histogram_of_projections(image_stack, I_shape, plot_path, log, fignum=1): """ Plots histograms for each projected stack in the given image stack and saves them as PDF files. Parameters ----------- image_stack : array-like The stack of 2D images for which histograms will be computed. I_shape : tuple The shape of the image stack (assumed to be in TZYX or TCZYX format). plot_path : str or Path The directory where the histogram plots will be saved. log : logger_object A logging object to record processing steps and timing. fignum : int, optional The figure number for plotting (default is 1). Returns -------- None The function saves histogram plots for each projected stack as PDF files. Notes ------ - Each stack slice is processed separately, and its histogram is saved as `<plot_title>.pdf`. - The function logs processing time and status using the provided logger. - Uses `plot_histogram()` internally to generate individual plots. """ Process_t0 = time.time() print(f"plotting the histograms of the projeted stacks...", end="") log.log(f"") for stack in range(I_shape[0]): plot_histogram(image_stack[stack], plot_path=plot_path, fignum=1, title=f"MG projected, histogram, stack {stack}", plot_title=f"MG projected, histogram, stack {stack}") _ = log.logt(Process_t0, verbose=True, spaces=2, unit="sec", process="histogram plotting ")
# %% PROJECTION PLOTTING HELPERS
[docs] def plot_projected_stack(image_stack, I_shape, plot_path, log, plottitle="MG projected"): """ Plots and saves z-projected image stacks as grayscale images and an OME-TIFF file. Parameters ----------- image_stack : array-like The stack of 2D projected images to be plotted and saved. I_shape : tuple The shape of the image stack, used to determine the number of stacks. plot_path : str or Path The directory where the plots and image file will be saved. log : logger_object A logging object to record processing steps and execution time. plottitle : str, optional The base title for the saved plots and image file (default is "MG projected"). Returns -------- None The function saves each projected stack as a grayscale plot and the full stack as an image file. Notes ------ - Individual stacks are plotted as grayscale images and saved as PDFs. - The full image stack is saved as an image file with metadata. - The function logs the plotting process and execution time. """ Process_t0 = time.time() log.log(f"plotting z-projections...") for stack in range(I_shape[0]): plot_2D_image(image_stack[stack], plot_path, plot_title=plottitle+", stack " + str(stack), fignum=9, cmap=plt.get_cmap('gist_gray'), cbar_label="", title=f"{plottitle}, stack {stack}", cbar_show=False) # cbar_ticks=np.arange(0,255,10), cbar_ticks_labels=np.arange(0,255,10), TIFF_path = os.path.join(plot_path, plottitle+".tif") write_image_stack(TIFF_path, image_stack.astype("float32")) _ = log.logt(Process_t0, verbose=True, spaces=2, unit="sec", process="z-projection plotting ")
[docs] def plot_projected_stack_as_tif(image_stack, I_shape, plot_path, log, plottitle="MG projected"): """ Saves z-projected image stacks as OME-TIFF files. Parameters ----------- image_stack : array-like The stack of 2D projected images to be saved as image files. I_shape : tuple The shape of the image stack, used to determine the number of stacks. plot_path : str or Path The directory where the image files will be saved. log : logger_object A logging object to record processing steps and execution time. plottitle : str, optional The base title for the saved image files (default is "MG projected"). Returns -------- None The function saves each projected stack as an individual image file. Notes ------ - Each stack is saved as a separate image file with a unique filename. - The function logs the saving process and execution time. """ Process_t0 = time.time() log.log(f"saving z-projections as image files...") for stack in range(I_shape[0]): plot_2D_image_as_tif(image=image_stack[stack], plot_path=plot_path, plot_title=plottitle+", stack " + str(stack)) _ = log.logt(Process_t0, verbose=True, spaces=2, unit="sec", process="z-projection tif saving ")
[docs] def z_max_project(MG_sub, I_shape, log): """ Computes the maximum intensity Z-projection of an image stack. Parameters ----------- MG_sub : array-like The input microglial image stack. I_shape : tuple Shape of the input image stack. log : logger_object Logging object for recording the process. Returns -------- MG_pro : ndarray The Z-projected image stack using maximum intensity projection. Notes ------ - This function collapses the Z-dimension by selecting the maximum intensity value for each pixel across all Z-slices. - Useful for visualizing microglial structures in a single 2D image. - Logs execution time for performance monitoring. """ Process_t0 = time.time() print(f"z-projecting...", end="") # First, verify that the input is a 3D array, otherwise return the input and a warning: if I_shape[1] == 1: log.log(f"WARNING: z_max_project: input is not a 3D array, returning input without projection.") return MG_sub MG_pro = np.zeros((I_shape[0], I_shape[-2], I_shape[-1])) for stack in range(I_shape[0]): MG_pro[stack] = np.max(MG_sub[stack], axis=0) _ = log.logt(Process_t0, verbose=True, spaces=2, unit="sec", process="z-projections ") return MG_pro
[docs] def compare_histograms(MG_sub_pre, MG_sub_post, log, plot_path, I_shape, xlim=(0,6000)): """ Compares histograms of projected stacks before and after histogram adjustments. Parameters ----------- MG_sub_pre : array-like The image stack before histogram adjustments. MG_sub_post : array-like The image stack after histogram adjustments. log : logger_object Logging object for recording the process. plot_path : str or Path The directory path where the histogram plots will be saved. I_shape : tuple Shape of the input image stack. xlim : tuple, optional Limits for the x-axis of the histogram (default is (0, 6000)). Returns -------- None The function saves the histogram plots as PDF files. Notes ------ - Each stack’s histogram is plotted and saved separately. - The function normalizes intensity values before plotting. - Uses a logarithmic scale for better visualization of histogram distributions. - Logs execution time for performance monitoring. """ Process_t0 = time.time() log.log(f"calculating and plotting histogram of each stack...") for stack in range(I_shape[0]): plt.close(1) fig = plt.figure(2, figsize=(8, 5)) plt.clf() if MG_sub_pre[stack].ravel().max() > 1: Curr_MG_sub_pre = MG_sub_pre[stack].ravel() / MG_sub_pre[stack].ravel().max() else: Curr_MG_sub_pre = MG_sub_pre[stack].ravel() _,_,_ = plt.hist(Curr_MG_sub_pre, bins=256, histtype='stepfilled', color="k", alpha=0.25, label="before adjustments") if MG_sub_post[stack].ravel().max()>1: Curr_MG_sub_post = MG_sub_post[stack].ravel()/MG_sub_post[stack].ravel().max() else: Curr_MG_sub_post = MG_sub_post[stack].ravel() _, _, _ = plt.hist(Curr_MG_sub_post, bins=256, histtype='stepfilled', color="lime", alpha=0.45, label="before adjustments") ax = plt.gca() ax.set_yscale('log') #plt.xlim(xlim) plt.legend() plt.xlabel("normalized brightness bins") plt.ylabel("counts (log-scale)") title = f"Histograms of projected stack before and after histogram adjustments, stack {stack}" plot_title = f"Stats Histograms before and after adjustments, stack {stack}" plt.title(title) plt.tight_layout() plt.savefig(Path(plot_path, plot_title + ".pdf"), dpi=120) plt.close(fig) _ = log.logt(Process_t0, verbose=True, spaces=2, unit="sec", process="histogram comparison ") return
[docs] def plot_intensities(MG_pro, log, plot_path, I_shape, table_export_formats=DEFAULT_TABLE_EXPORT_FORMATS): """ Plots and saves the normalized average brightness per projected stack. Parameters ----------- MG_pro : array-like The projected image stack. log : logger_object Logging object for recording the process. plot_path : str or Path The directory path where the plot and data file will be saved. I_shape : tuple Shape of the input image stack. Returns -------- intensity_means : ndarray Array containing the mean intensity values for each stack. Notes ------ - The function calculates the average intensity for each projected stack. - Normalizes intensity values relative to the first stack. - Saves a bar plot and table file(s) with the normalized brightness values. - Includes grid lines for easier comparison. - Logs execution time for performance monitoring. """ Process_t0 = time.time() log.log(f"plotting average brightness per projected stack...") intensity_means = np.zeros(I_shape[0]) for stack in range(I_shape[0]): intensity_means[stack] = MG_pro[stack].mean() # plot normalized average brightness drop rel. to stack 0 plt.close(1) fig = plt.figure(2, figsize=(5, 3.5)) plt.clf() plt.axhline(y=130, color="k", linestyle='--', lw=0.75, alpha=0.5) plt.axhline(y=120, color="k", linestyle='--', lw=0.75, alpha=0.5) plt.axhline(y=110, color="k", linestyle='--', lw=0.75, alpha=0.5) plt.axhline(y=100, color="k", linestyle='--', lw=0.75, alpha=0.5) plt.axhline(y=90, color="k", linestyle='--', lw=0.75, alpha=0.5) plt.axhline(y=80, color="k", linestyle='--', lw=0.75, alpha=0.5) plt.axhline(y=66, color="k", linestyle='--', lw=0.75, alpha=0.5) plt.axhline(y=50, color="k", linestyle='--', lw=0.75, alpha=0.5) plt.axhline(y=33, color="k", linestyle='--', lw=0.75, alpha=0.5) plt.axhline(y=25, color="k", linestyle='--', lw=0.75, alpha=0.5) plt.bar(np.arange(I_shape[0]), 100 * intensity_means / intensity_means[0], zorder=3) max_y_val = np.max(100 *intensity_means / intensity_means[0]) if np.isnan(max_y_val) or np.isinf(max_y_val): max_y_val = 100 plt.ylim(0, max_y_val+5) plt.xlim(-0.5, I_shape[0]-0.5) plt.xticks(np.arange(I_shape[0]), labels=[f"$t_{i}$" for i in range(I_shape[0])]) plt.yticks(np.arange(0,max_y_val+5, 10)) plt.xlabel("stack") plt.ylabel("normalized brightness [%]") title = f"Average cell brightness relative to $t_0$" plot_title = f"Normalized average brightness drop rel. to t0" plt.title(title) # turn off right and top axis: plt.gca().spines['right'].set_visible(False) plt.gca().spines['top'].set_visible(False) plt.gca().spines['bottom'].set_visible(False) plt.gca().spines['left'].set_visible(False) # set fontsize to 14 for the current figure: plt.setp(plt.gca().get_xticklabels(), fontsize=14) plt.setp(plt.gca().get_yticklabels(), fontsize=14) plt.gca().title.set_fontsize(14) plt.gca().xaxis.label.set_fontsize(14) plt.gca().yaxis.label.set_fontsize(14) plt.tight_layout() plt.savefig(Path(plot_path, plot_title + ".pdf"), dpi=120) plt.close(fig) df_out = pd.DataFrame(data=intensity_means, columns=["Normalized (btw. 0 and 1) average brightness of each stack"]) df_out["t_i"] = np.arange(I_shape[0]) # move ["t_i"] to the first column: cols = df_out.columns.tolist() cols = cols[-1:] + cols[:-1] df_out = df_out[cols] export_dataframe( df_out, os.path.join(plot_path, "Normalized average brightness of each stack.xlsx"), table_export_formats=table_export_formats, ) _ = log.logt(Process_t0, verbose=True, spaces=2, unit="sec", process="brightness comparison ") return intensity_means
# %% END