import math
from pathlib import Path
from typing import Iterable, Tuple, List, Dict, Optional, TYPE_CHECKING, Any
import numpy as np
import pandas as pd
from pandas import DataFrame
from scipy import stats
# import mpld3 # WARNING: local import. Present here only for reference
# from PyQt5.QtWidgets import QApplication
from ClearMap.IO import IO as clm_io
from ClearMap.Analysis.Statistics.group_statistics import (generate_summary_table, group_region_counts,
remove_p_val_nans, stack_voxelizations, get_colored_p_vals)
from .generic_orchestrators import GroupOrchestratorBase
from .utils import init_sample_manager_and_processors
from ..Alignment.utils import get_all_region_ids
from ..Analysis.Statistics.group_assets import GroupResultsAssets
from ..IO.assets_constants import CHANNELS_ASSETS_TYPES_CONFIG
from ..Utils.exceptions import GroupStatsError
from ..Visualization.Qt.Plot3d import PlotPanel, multi_plot_from_panels
from ..config.atlas import ATLAS_NAMES_MAP
from ..config.compound_keys import CompoundKey
if TYPE_CHECKING:
from ClearMap.IO.workspace_asset import Asset
from PyQt5.QtWebEngineWidgets import QWebEngineView
Pair = Tuple[str, str]
FILE_SIZE_TOLERANCE = 1024
def _pipeline_points_asset_map() -> dict[str, str]:
"""
For each pipeline, return the first table-format asset that is relevant to it.
Derivation order matches insertion order of CHANNELS_ASSETS_TYPES_CONFIG.
Examples
-------
>>>print(_pipeline_points_asset_map())
{'CellMap': 'cells',
'TractMap': 'tract_voxels',
'Colocalization': 'colocalization',
'TubeMap': 'vertices'
}
"""
result: dict[str, str] = {}
for asset_type, cfg in CHANNELS_ASSETS_TYPES_CONFIG.items():
if cfg.get('file_format_category') != 'table':
continue
for pipeline in cfg.get('relevant_pipelines', []):
if pipeline not in result:
result[pipeline] = asset_type
return result
_PIPELINE_POINTS_ASSET: dict[str, str] = _pipeline_points_asset_map()
[docs]
class DensityGroupAnalysisOrchestrator(GroupOrchestratorBase):
"""Group-level statistics & plots for density maps."""
def __init__(self, *, group_controller, pipeline: str, groups=None, channel=None):
super().__init__(group_controller=group_controller,
groups=groups, channel=channel)
self.pipeline = pipeline
self._assets: Optional[GroupResultsAssets] = None
@property
def assets(self) -> GroupResultsAssets:
current = self.results_folder
if self._assets is None or self._assets.root != current:
self._assets = GroupResultsAssets(current)
return self._assets
# ── sample asset resolution ─────────
def _points_df(self, sample_dir: Path, channel) -> pd.DataFrame:
sm = self.get_sample_manager_for(sample_dir)
if CompoundKey.is_valid_key_str(channel): # If we deal with CompoundKey -> split to components
channel = CompoundKey.from_string(channel).as_tuple()
asset = sm.get(self._points_asset_type(), channel=channel)
if not asset.exists:
raise FileNotFoundError(f'No {self._points_asset_type()} for {channel=} in {sample_dir}')
df = pd.read_feather(asset.path)
self._ensure_registered(df, channel)
return df
def _ensure_registered(self, df: DataFrame, channel: tuple[str, ...] | tuple[str, str] | Any):
if 'id' not in df.columns:
raise ValueError(
f'{self._points_asset_type()} for {channel=} has no "id" column. '
f'Ensure registration and annotation were run.')
def _density_asset(self, sample_dir: Path, channel: str, suffix: str) -> 'Asset':
sm = self.get_sample_manager_for(sample_dir)
asset = sm.get('density', channel=channel, asset_sub_type=suffix or None)
return asset
def _density_array(self, sample_dir: Path, channel: str, suffix: str) -> np.ndarray:
asset = self._density_asset(sample_dir, channel, suffix)
if not asset.exists:
raise FileNotFoundError(f'No density for {channel=}, {suffix=} in {sample_dir}')
return clm_io.read(asset.path)
def _points_asset_type(self) -> str:
asset = _PIPELINE_POINTS_ASSET.get(self.pipeline)
if asset is None:
raise ValueError(f'No points asset defined for {self.pipeline=}')
return asset
def _cells_df(self, sample_dir: Path, channel: str) -> pd.DataFrame:
sm = self.get_sample_manager_for(sample_dir)
asset = sm.get('cells', channel=channel)
if not asset.exists:
raise FileNotFoundError(f'No cells for {channel=} in {sample_dir}')
return pd.read_feather(asset.path)
def _sample_id(self, sample_dir: Path) -> str:
sm = self.get_sample_manager_for(sample_dir)
return sm.config['sample_id']
# ── validation ───────────────────────────────────────────────────────
[docs]
def density_files_are_comparable(self, gp1_dirs, gp2_dirs,
channel: str, suffix: str = '') -> bool:
"""Raise GroupStatsError if density file sizes differ across groups."""
sizes = []
for d in list(gp1_dirs) + list(gp2_dirs):
asset = self._density_asset(d, channel=channel, suffix=suffix or None)
sizes.append(asset.path.stat().st_size)
def format_sizes(paths_list):
return '\t\n'.join([f'{f_p}: {f_p.stat().st_size:,}' for f_p in paths_list])
def _resolve_density_path(folder):
asset = self._density_asset(d, channel=channel, suffix=suffix)
return asset.path
if not all(math.isclose(s, sizes[0], abs_tol=FILE_SIZE_TOLERANCE) for s in sizes):
gp1_f_list = [_resolve_density_path(d) for d in gp1_dirs]
gp2_f_list = [_resolve_density_path(d) for d in gp2_dirs]
raise GroupStatsError(f'Density file sizes differ for {channel=}, sizes differ\n'
f'Group 1: \t\n{format_sizes(gp1_f_list)}\n'
f'Group 2: \t\n{format_sizes(gp2_f_list)}\n')
return True
[docs]
def check_ids_are_unique(self, gp1_dirs, gp2_dirs) -> None:
ids = [self._sample_id(Path(d)) for d in list(gp1_dirs) + list(gp2_dirs)]
if len(ids) != len(set(ids)):
raise GroupStatsError(f'Sample IDs are not unique: {ids=}')
# ── computation ──────────────────────────────────────────────────────
[docs]
def compute_p_values(self, comparisons: List[Pair], *, channels: Iterable[str],
advanced: bool, density_files_suffix: str) -> None:
if not self.groups:
raise ValueError('No groups defined')
self.results_folder.mkdir(parents=True, exist_ok=True)
for gp1_name, gp2_name in comparisons:
gp1_dirs = self.groups[gp1_name]
gp2_dirs = self.groups[gp2_name]
# sanity checks
for ch in channels:
self.density_files_are_comparable(gp1_dirs, gp2_dirs, ch, density_files_suffix)
self.check_ids_are_unique(gp1_dirs, gp2_dirs)
# compute (threaded if wrapper provided)
self._threaded(self._compute_pair, gp1_name, gp2_name,
channels=channels, advanced=advanced,
suffix=density_files_suffix)
self._increment_progress_main()
# REFACTOR: more explicit name
def _compute_pair(self, gp1_name: str, gp2_name: str, *,
channels: list[str], advanced: bool, suffix: str) -> None:
for channel in channels:
# ── load ─────────────────────────────────────────────────────────
gp1_arrays = [self._density_array(Path(d), channel, suffix)
for d in self.groups[gp1_name]]
gp2_arrays = [self._density_array(Path(d), channel, suffix)
for d in self.groups[gp2_name]]
# ── pure math (group_statistics) ─────────────────────────────────
gp1_stacked = stack_voxelizations(gp1_arrays) # (X, Y, Z, N)
gp2_stacked = stack_voxelizations(gp2_arrays)
t_vals, p_vals = stats.ttest_ind(gp1_stacked, gp2_stacked,
axis=3, equal_var=False)
p_vals, t_vals = remove_p_val_nans(p_vals, t_vals)
colored = np.maximum(
get_colored_p_vals(p_vals, t_vals, 0.05, ('red', 'green')),
get_colored_p_vals(p_vals, t_vals, 0.01, ('green', 'blue'))
).astype(np.uint8)
if advanced:
effect_size = np.abs(gp1_stacked.mean(axis=3).astype(int) - gp2_stacked.mean(axis=3).astype(int))
effect_size = effect_size.astype(np.uint16) # for imagej compatibility
# ── write ────────
self.assets.write_avg_density(gp1_stacked.mean(axis=3), channel, gp1_name, suffix)
self.assets.write_avg_density(gp2_stacked.mean(axis=3), channel, gp2_name, suffix)
self.assets.write_p_val_colors(colored, channel, gp1_name, gp2_name, suffix)
if advanced:
self.assets.write_sd_density(gp1_stacked.std(axis=3), channel, gp1_name, suffix)
self.assets.write_sd_density(gp2_stacked.std(axis=3), channel, gp2_name, suffix)
self.assets.write_effect_size(effect_size, channel, gp1_name, gp2_name, suffix)
# FIXME: return dict {channel_name: colored_p_vals}
[docs]
def find_available_density_suffixes(self) -> List[str]:
"""Scan the first sample's workspace to find available density suffixes."""
if not self.groups:
return []
first_group = next(iter(self.groups.values()), None)
sm = self.get_sample_manager_for(Path(first_group[0]))
# Get target channel(s) depending on the pipeline
channels = sm.get_channels_by_pipeline(self.pipeline, as_list=True)
if not channels:
return []
if self.pipeline == 'TubeMap':
target_ch = tuple(channels) # Compund channel for the graph
else:
target_ch = channels[0] # Just use the first one to probe
suffixes = set()
# Probe the workspace info dictionary for density files matching our channel
for asset_key in sm.workspace.info_dict().keys():
# asset_key is a tuple: (type, channel, subtype)
if len(asset_key) >= 3 and asset_key[0] == 'density' and asset_key[1] == target_ch:
suffixes.add(asset_key[2] or '') # subtype is the suffix
# Fallback if scanning fails/is empty but we know defaults
if not suffixes:
if self.pipeline == 'TubeMap':
suffixes = {'branches'}
else:
suffixes = {'counts'}
return sorted(list(suffixes))
[docs]
def find_analysable_channels(self, *, density_suffix: str | None) -> List[str]:
"""
Inspects the first sample of the first group to infer which channels have density maps.
"""
if not self.groups:
return []
first_group = next(iter(self.groups.values()))
if not first_group:
return []
first_folder = Path(first_group[0])
sample_mgr = self.get_sample_manager_for(sample_src_dir=first_folder)
channels = []
for ch in sample_mgr.pipeline_ready_channels: # individual channels (CellMap, TractMap, Colocalization)
asset = sample_mgr.get('density', channel=ch, suffix=density_suffix, default=None)
if asset is not None and asset.exists:
channels.append(ch)
vasc_channels = sample_mgr.get_channels_by_pipeline('TubeMap', as_list=True)
if vasc_channels:
compound = tuple(vasc_channels)
asset = sample_mgr.get('density', channel=compound, asset_sub_type=density_suffix or None, default=None)
if asset is not None and asset.exists:
channels.append(compound)
return channels
def _get_annotator(self, sample_dir: Path, channel: str):
reg = self.get_worker_for_sample(sample_dir, 'registration', channel=None)
reg.setup_if_needed()
return reg.annotators[channel]
[docs]
def run_plots(self, plot_function, comparisons: List[Pair], *,
channel: str, plot_kw_args: Dict) -> list['QWebEngineView']:
from PyQt5.QtWidgets import QApplication
app = QApplication.instance()
if app is not None and app.applicationName() == 'ClearMap':
from PyQt5.QtWebEngineWidgets import QWebEngineView
import mpld3
dvs = []
for gp1_name, gp2_name in comparisons:
if plot_kw_args.get('group_names') is None:
kwargs = dict(plot_kw_args, group_names=(gp1_name, gp2_name))
else:
kwargs = plot_kw_args
df = pd.read_csv(self.assets.stats_table_path(channel, gp1_name, gp2_name))
fig = plot_function(df, **kwargs)
web_view = QWebEngineView()
web_view.setHtml(mpld3.fig_to_html(fig))
dvs.append(web_view)
return dvs
[docs]
def compute_stats_tables(self, comparisons, *, channel: str | None = None, density_suffix: str | None = None, save=True):
# FIXME: add save
"""
Moves the heavy lifting out of the tab:
- runs make_summary per pair (threaded if a wrapper is set)
- returns { (gp1, gp2): { channel: DataFrame } }
"""
out = {}
for gp1_name, gp2_name in comparisons:
dfs = self._threaded(
self._compute_summary, gp1_name, gp2_name,
channel=channel, density_suffix=density_suffix, save=save)
out[(gp1_name, gp2_name)] = dfs
self._increment_progress_main()
return out
def _compute_summary(self, gp1_name: str, gp2_name: str, *,
channel: str | None, density_suffix: str | None = None, save: bool) -> dict[str, pd.DataFrame]:
ref_dir = Path(self.groups[gp1_name][0])
orchestrators = init_sample_manager_and_processors(folder=ref_dir)
aligner = orchestrators['registration_processor']
sm = self.get_sample_manager_for(ref_dir)
channels = [channel] if channel else self.find_analysable_channels(density_suffix=density_suffix)
result = {}
for ch in channels:
# FIXME: replace by _get_annotator
# FIXME: reinstate annotator_factory
# annotator_factory : callable(folder, channel) -> (annotator, atlas_scale), optional
# If provided, used to obtain an annotator and atlas scale for a sample.
# Defaults to building full processors via ``init_sample_manager_and_processors``.
annotator = aligner.annotators[ch or sm.alignment_reference_channel]
atlas_id = aligner.config['atlas']['id']
atlas_scale = [ATLAS_NAMES_MAP[atlas_id]['resolution']] * 3
volume_map = annotator.get_lateralised_volume_map(atlas_scale)
gp1_dfs = [self._points_df(Path(d), ch) for d in self.groups[gp1_name]]
gp2_dfs = [self._points_df(Path(d), ch) for d in self.groups[gp2_name]]
region_ids = get_all_region_ids(gp1_dfs + gp2_dfs)
aggregated = {
gp1_name: group_region_counts(
annotator, region_ids, gp1_dfs,
[self._sample_id(Path(d)) for d in self.groups[gp1_name]],
volume_map),
gp2_name: group_region_counts(
annotator, region_ids, gp2_dfs,
[self._sample_id(Path(d)) for d in self.groups[gp2_name]],
volume_map),
}
total_df = generate_summary_table(aggregated)
if save:
self.assets.write_stats_table(total_df, ch, gp1_name, gp2_name)
result[ch] = total_df
return result
# ---------- plots ----------
[docs]
def plot_p_value_maps(self, comparisons: List[Pair], *, channel: str, suffix: str, advanced: bool = False, parent=None):
from ClearMap.Visualization.Qt import Plot3d as plot_3d
from ClearMap.Visualization.Qt.utils import link_dataviewers_cursors
if len(comparisons) > 1: # Multiple comparisons: just show p-value maps
p_val_imgs = []
for gp1, gp2 in comparisons:
p_path = self.assets.p_val_colors_path(channel, gp1, gp2, suffix)
p_val_imgs.append(clm_io.read(p_path))
titles = [f'{gp1} vs {gp2} p values' for gp1, gp2 in comparisons]
dvs = plot_3d.plot(p_val_imgs, title=titles, arrange=False, sync=True, parent=parent)
else: # If only one comparison, show more details (avg, sd, effect size, atlas (+ sd and effect_size if advanced)
gp1, gp2 = comparisons[0]
res = self.assets.load_p_val_results(channel, gp1, gp2, suffix)
sample_dir = self._any_sample_in(gp1)
annotator = self._get_annotator(sample_dir, channel=channel)
colored_atlas = annotator.create_color_annotation()
panels = [
PlotPanel(images=res.gp1_avg, title=f'{gp1} avg', lut='flame'),
PlotPanel(images=res.gp2_avg, title=f'{gp2} avg', lut='flame'),
PlotPanel(images=res.p_vals, title='P values', lut=None),
]
if advanced:
if res.gp1_sd is not None:
panels.append(PlotPanel(images=res.gp1_sd, title=f'{gp1} SD', lut='viridis'))
if res.gp2_sd is not None:
panels.append(PlotPanel(images=res.gp2_sd, title=f'{gp2} SD', lut='viridis'))
if res.effect_size is not None:
panels.append(PlotPanel(images=res.effect_size, title='Effect size', lut='flame'))
panels.append(
PlotPanel(images=colored_atlas, title='Atlas', lut=None, min_max=(0, 255))
)
dvs = multi_plot_from_panels(panels, arrange=False, sync=True, parent=parent)
names_map = annotator.get_names_map()
for dv in dvs:
dv.atlas = annotator.atlas
dv.structure_names = names_map
link_dataviewers_cursors(dvs)
return dvs
[docs]
def plot_density_maps(self, group_folders: List[str], *, channel: str, density_suffix: str, parent=None):
from ClearMap.Visualization.Qt import Plot3d as plot_3d
from ClearMap.Visualization.Qt.utils import link_dataviewers_cursors
paths, titles = [], []
for folder in group_folders:
asset = self._density_asset(folder, channel, suffix=density_suffix or None)
paths.append(asset.path)
titles.append(self._sample_id(folder))
dvs = plot_3d.plot(paths, title=titles, arrange=False, sync=True, lut=['flame']*len(paths), parent=parent)
link_dataviewers_cursors(dvs)
return dvs