"""
sample_info_management
======================
Sample-level metadata management, configuration synchronisation,
workspace reconciliation, and channel queries.
:class:`SampleManager` is the **root object** for a single ClearMap
experiment. It owns the sample configuration (channel paths, resolutions,
orientations, data types) and keeps the
:class:`~ClearMap.IO.workspace2.Workspace2` in sync with it.
Every pipeline worker receives a ``SampleManager`` reference so it can
resolve asset paths without knowing the experiment layout.
Responsibilities
----------------
**Configuration access**
:attr:`~SampleManager.channels`, :attr:`~SampleManager.data_types`,
:meth:`~SampleManager.get_channel_resolution`, etc. expose the sample
config as typed Python values, always reflecting the latest committed
state from :class:`~ClearMap.config.config_coordinator.ConfigCoordinator`.
**Workspace reconciliation**
:meth:`~SampleManager.update_workspace` ensures the
:class:`~ClearMap.IO.workspace2.Workspace2` mirrors the current channel
list — adding missing channels, updating raw-data paths, pruning deleted
channels, and persisting ``workspace.yml``.
**Channel queries**
:meth:`~SampleManager.get_channels_by_type`,
:meth:`~SampleManager.get_channels_by_pipeline`, and
:meth:`~SampleManager.get_channels_by_condition` provide filtered
lookups with configurable ``missing_action`` / ``multiple_found_action``
policies (``'ignore'``, ``'warn'``, ``'raise'``).
**Pipeline discovery**
:meth:`~SampleManager.compute_relevant_pipelines` infers which
pipelines are active so the
:class:`~ClearMap.pipeline_orchestrators.experiment_controller.ExperimentController`
knows which config sections to load.
**Asset access**
Inherits :meth:`~.generic_orchestrators.OrchestratorBase.get` and
:meth:`~.generic_orchestrators.OrchestratorBase.get_path`.
``@adjuster_safe`` marker
-------------------------
Methods decorated with :func:`adjuster_safe` are safe to call from config
adjusters before all channel configs are fully populated.
:func:`check_protocol_coverage` verifies at module load time that every
method in
:class:`~ClearMap.config.config_adjusters.type_hints.SampleManagerProtocol`
carries this marker.
Bootstrapping
-------------
Use :func:`build_sample_manager` rather than constructing
:class:`SampleManager` directly::
from ClearMap.pipeline_orchestrators.sample_info_management import build_sample_manager
sm = build_sample_manager('/path/to/experiment')
print(sm.channels) # ['cfos', 'autofluorescence']
print(sm.get_channels_by_pipeline('CellMap')) # ['cfos']
print(sm.get_channel_resolution('cfos')) # (1.625, 1.625, 3.0)
raw = sm.get('raw', channel='cfos')
print(raw.is_tiled, raw.tile_grid_shape) # True, array([3, 4])
See also
--------
:class:`~ClearMap.IO.workspace2.Workspace2` : Asset management layer.
:class:`~ClearMap.pipeline_orchestrators.experiment_controller.ExperimentController` :
Owns SampleManager and wires it to pipeline workers.
:mod:`ClearMap.IO.assets_constants` : ``CONTENT_TYPE_TO_PIPELINE`` mapping.
"""
import atexit
import getpass
import os
import re
import shutil
import tempfile
import warnings
from pathlib import Path
from typing import Optional, Callable, List, Dict
import numpy as np
from ClearMap.IO.assets_constants import CONTENT_TYPE_TO_PIPELINE
from ClearMap.Utils.events import ChannelRenamed, CfgChanged
from ..Utils.event_bus import EventBus
from ..config.compound_keys import PairKey
from ..config.config_adjusters.type_hints import SampleManagerProtocol
from ..config.config_handler import ALTERNATIVES_REG
from ClearMap.config.config_coordinator import ConfigCoordinator, make_cfg_coordinator_factory
# noinspection PyPep8Naming
import ClearMap.Alignment.Resampling as resampling
# noinspection PyPep8Naming
from ClearMap.IO.workspace2 import Workspace2
from .generic_orchestrators import OrchestratorBase
__author__ = 'Christoph Kirst <christoph.kirst.ck@gmail.com>, Charly Rousseau <charly.rousseau@icm-institute.org>'
__license__ = 'GPLv3 - GNU General Public License v3 (see LICENSE)'
__copyright__ = 'Copyright © 2020 by Christoph Kirst'
__webpage__ = 'https://idisco.info'
__download__ = 'https://github.com/ClearAnatomics/ClearMap'
[docs]
def adjuster_safe(fn):
"""
No-op marker: 'this method tolerates incomplete channel configs'.
This is meant to label SampleManager methods that are used in config adjusters (SampleManagerProtocol)
and that can be safely called even if some channels have incomplete configs (e.g. missing path).
"""
fn._adjuster_safe = True
return fn
[docs]
class SampleManager(OrchestratorBase):
"""
This class is used to manage the sample information
Manage sample-level configurations and properties.
Handle configurations related to the sample.
Provide utility methods for checking sample properties.
"""
config_name = 'sample'
def __init__(self, config_coordinator: "ConfigCoordinator", src_dir: Optional[Path | str] = None):
super().__init__(config_coordinator)
self.incomplete_channels = []
self.setup_complete = False
self.workspace: Optional[Workspace2] = None # Defined in update_workspace
self._renamed_channels: dict[str, str] = {}
self.subscribe(ChannelRenamed, self._on_channel_renamed)
self.subscribe(CfgChanged, self._on_cfg_changed)
self.resource_type_to_folder: Optional[dict] = None
self.setup(src_dir=src_dir)
[docs]
def setup(self, src_dir: Optional[Path | str] = None):
"""
Setup the sample manager with the given configs.
Parameters
----------
src_dir : str | Path | None
The source directory of the sample
"""
if src_dir is not None:
src_dir = Path(src_dir).expanduser().resolve()
# Invalidate workspace if directory changed
if self.workspace is not None:
old_dir = Path(self.workspace.directory).resolve()
if old_dir != src_dir:
self.workspace = None
self.resource_type_to_folder = None
self.cfg_coordinator.set_base_dir(src_dir)
# FIXME: add to set_base_dir ?
self.cfg_coordinator.load('sample') # need to load at least sample config to know pipelines
if not self.config:
if not src_dir.exists():
raise FileNotFoundError(f'Specified source directory {src_dir} does not exist')
else:
return
# raise RuntimeError(f'Failed to load sample config from {src_dir}. Unknown error.')
workspace_path = self.cfg_coordinator.workspace_config_path
if workspace_path.exists():
workspace = Workspace2.from_yaml(workspace_path)
# Ensure loaded workspace points to the right directory
if str(Path(workspace.directory).resolve()) != str(src_dir):
workspace.directory = str(src_dir)
self.workspace = workspace
self.resource_type_to_folder = workspace.resource_type_to_folder
self.update_workspace()
desired_sample_id = self.prefix # None when use_id_as_prefix is False
if desired_sample_id != self.workspace.sample_id:
self.workspace.set_sample_id(desired_sample_id or '')
sections = self.compute_required_sections()
self.cfg_coordinator.load_all(sections)
self.setup_complete = (not self.incomplete_channels) and bool(self.config)
[docs]
@adjuster_safe
def compute_required_sections(self) -> set[str]:
"""
Given this controller’s SampleManager (single-sample view),
compute which config sections should exist for this experiment.
"""
sections: set[str] = {'sample'} # always # TODO: check if ordered
pipelines = self.compute_relevant_pipelines()
for p in pipelines:
sec = ALTERNATIVES_REG.pipeline_to_section_name(p)
if sec:
sections.add(sec)
return sections
def _on_cfg_changed(self, evt: CfgChanged) -> None:
# Only reconcile when sample/channels subtree changed.
if not evt.changed_keys:
return
needs_workspace_update = any(
k == 'sample' or # whole sample section changed
k.startswith('sample.channels') or # channels subtree changed
k == 'sample.use_id_as_prefix' or # use_id_as_prefix affects prefix and thus asset paths
k == 'sample.sample_id' # sample_id affects prefix and thus asset paths
for k in evt.changed_keys
)
if needs_workspace_update:
self.update_workspace()
[docs]
def patch_channel(self, channel, patch: dict):
self.cfg_coordinator.submit_patch({self.config_name: {'channels': {channel: patch}}},
sample_manager=self)
[docs]
def set_channel_expression(self, channel: str, expression: "str | tag_expression.Expression"):
self.patch_channel(channel, patch={'path': expression})
[docs]
def set_channel_resolution(self, channel: str, resolution: tuple[float, float, float]):
self.patch_channel(channel, patch={'resolution': list(resolution)})
def _on_channel_renamed(self, event: ChannelRenamed):
if self.workspace:
try:
self.workspace.rename_channel(event.old, event.new)
except Exception:
self.update_workspace() # Full reconciliation if rename fails
[docs]
def rename_channels_in_workspace(self, names_map: Dict[str, str]):
if not self.workspace:
return
for old_name, new_name in names_map.items():
if old_name and old_name != new_name:
self.workspace.rename_channel(old_name, new_name)
[docs]
def update_workspace(self):
if not self.config or 'channels' not in self.config:
# Nothing to do yet (cannot even create workspace) — config not loaded or new experiment
self.incomplete_channels = []
return
self._ensure_workspace()
desired_sample_id = self.prefix # None when use_id_as_prefix is False
current_sample_id = self.workspace.sample_id
if desired_sample_id != current_sample_id:
self.workspace.set_sample_id(desired_sample_id or '')
self.incomplete_channels = []
# Add or update channels in the workspace
for channel, cfg in self.config['channels'].items():
if not isinstance(cfg, dict):
self.incomplete_channels.append(channel)
continue
raw_path = cfg.get('path', '')
data_content_type = cfg.get('data_type')
if not raw_path or not data_content_type:
self.incomplete_channels.append(channel)
else:
if channel in self.workspace: # exists -> update
self.workspace.update_raw_path(channel, expression=raw_path)
if data_content_type in CONTENT_TYPE_TO_PIPELINE: # WARNING: no 'compound' here
channel_spec = self.workspace[channel].channel_spec
self.workspace.update_pipeline_assets(channel_spec, data_content_type, sample_id=self.prefix)
else: # new channel -> add
if data_content_type == 'undefined': # Difference with None is semantic (intention)
self.incomplete_channels.append(channel)
continue
self.workspace.add_raw_data(file_path=raw_path, channel_id=channel,
data_content_type=data_content_type, sample_id=self.prefix)
# Prune channels that are not in the config anymore
self.workspace.prune_missing_channels(self.channels)
self.workspace.ensure_default_channel(self.channels, self.channels[0] if self.channels else None)
print(self.workspace.info())
self.save_workspace()
[docs]
def save_workspace(self):
workspace_cfg_path = self.cfg_coordinator.workspace_config_path
if workspace_cfg_path.suffix in {'.yml', '.yaml'}:
self.workspace.to_yaml(workspace_cfg_path)
else:
# legacy fallback
self.workspace.save(workspace_cfg_path)
def _ensure_workspace(self):
current_base = str(Path(self.cfg_coordinator.base_dir).resolve())
# Stale workspace pointing to a different directory
if self.workspace is not None:
ws_dir = str(Path(self.workspace.directory).resolve())
if ws_dir != current_base:
self.workspace = None
if self.workspace is None:
first_channel = self.channels[0] if self.channels else None
workspace_cfg_path = self.cfg_coordinator.workspace_config_path
if workspace_cfg_path.exists():
if workspace_cfg_path.suffix in {'.yml', '.yaml'}:
self.workspace = Workspace2.from_yaml(workspace_cfg_path)
else: # legacy fallback
self.workspace = Workspace2.load(workspace_cfg_path)
# Patch directory if YAML contains a stale path
if str(Path(self.workspace.directory).resolve()) != current_base:
self.workspace.directory = current_base # set and propagate to assets
self.save_workspace() # persist so next load is correct
else:
self.workspace = Workspace2(current_base,
sample_id=self.prefix,
default_channel=first_channel,
resource_type_to_folder=self.resource_type_to_folder)
self.resource_type_to_folder = self.workspace.resource_type_to_folder
[docs]
def set_resource_type_to_folder(self, new_mapping: dict, *,
migrate: bool = False, dry_run: bool = False) -> dict[str, tuple[Path, Path]]:
"""
Update the workspace's resource_type_to_folder layout.
- If dry_run=True:
* compute and return the migration plan,
* DO NOT move files,
* DO NOT change workspace or persist anything.
- If dry_run=False:
* apply layout to the workspace (optionally migrating),
* update SampleManager.resource_type_to_folder,
* persist the workspace.
"""
self._ensure_workspace()
plan = self.workspace.sync_resource_type_to_folder(new_mapping, migrate=migrate, dry_run=dry_run)
if not dry_run:
# Keep SM in sync with Workspace2
self.resource_type_to_folder = self.workspace.resource_type_to_folder
# Persist new layout + types/channels snapshot
self.save_workspace()
return plan
@property
def prefix(self) -> Optional[str]:
"""
Get the prefix to use for the files
Returns
-------
str
The prefix to use, None to not use any
"""
return self.config['sample_id'] if self.config['use_id_as_prefix'] else None
@property
@adjuster_safe
def channels(self) -> list[str]:
cfg = self.config
if not cfg or 'channels' not in cfg:
return []
return list(cfg['channels'].keys())
@property
def pipeline_ready_channels(self) -> list[str]:
"""Channels with a meaningful data_type (excludes undefined/unconfigured)."""
excluded = (None, 'undefined', 'no-pipeline')
return [c for c in self.channels if self.data_type(c) not in excluded]
@property
@adjuster_safe
def renamed_channels(self) -> dict[str, str]:
return dict(self._renamed_channels) # copy to discourage mutation
[docs]
def set_renamed_channels(self, mapping: dict[str, str]) -> None:
self._renamed_channels = dict(mapping or {})
[docs]
def clear_renamed_channels(self) -> None:
self._renamed_channels = {}
[docs]
def infer_channel_index_from_name(self, path: Path | str) -> int | None:
"""
Extract channel index from typical microscopy filenames.
Extracts Cxx from filenames like ``_C00.ome.tif`` -> 0.
Parameters
----------
path : Path or str
Microscopy image filename.
Returns
-------
int or None
Channel index extracted from Cxx pattern, or None if not found.
Examples
--------
>>> infer_channel_index_from_name("image_C03.ome.tif")
3
"""
path = Path(path)
match = re.search(r"[Cc](\d{2})", path.name)
return int(match.group(1)) if match else None
[docs]
@adjuster_safe
def data_type(self, channel: str) -> str: # WARNING: ConfigObj Section does not support get() method
return self.config.get('channels', {}).get(channel, {}).get('data_type', 'undefined')
@property
def data_types(self) -> list[str]:
return [self.data_type(ch) for ch in self.channels]
@property
@adjuster_safe
def channels_to_detect(self) -> list[str]:
return self.get_channels_by_pipeline('CellMap', as_list=True)
@property
@adjuster_safe
def is_colocalization_compatible(self) -> bool:
return len(self.channels_to_detect) > 1
[docs]
@adjuster_safe
def colocalization_pairs(self) -> list[tuple[str, str]]:
if not self.is_colocalization_compatible:
return []
src = self.channels_to_detect
seen = set()
deduped: list[str] = []
for ch in src:
if ch not in seen:
deduped.append(ch)
seen.add(ch)
out: list[tuple[str, str]] = []
for i in range(len(deduped)):
for j in range(i + 1, len(deduped)):
out.append((deduped[i], deduped[j])) # preserve order from channels_to_detect
return out
[docs]
@adjuster_safe
def colocalization_pair_keys(self, *, oriented: bool) -> list[str]:
return [str(PairKey(a, b, oriented=oriented)) for a, b in self.colocalization_pairs()]
@property
def relevant_pipelines(self) -> list[str]:
"""
All the pipelines relevant to any of the sample channels
Returns
-------
List[str]
The relevant pipeline names
"""
pipelines = [CONTENT_TYPE_TO_PIPELINE.get(d_type) for d_type in self.data_types]
pipelines = list(set([p for p in pipelines if p is not None]))
return pipelines
[docs]
def z_only(self, channel) -> bool:
"""
Check if the channel is z only (no x or y tiles)
Parameters
----------
channel : str
The channel to check
Returns
-------
bool
True if the channel is z only
"""
return self.get('raw', channel, sample_id=self.prefix).tag_names == ['Z']
[docs]
def is_tiled(self, channel) -> bool:
asset = self.get('raw', channel, sample_id=self.prefix)
return asset.is_tiled and not self.z_only(channel)
@property
def autofluorescence_is_tiled(self) -> bool:
"""
Check if the autofluorescence channel is tiled (has x and y tiles)
Returns
-------
bool
True if the autofluorescence channel is tiled
"""
return self.is_tiled(self.alignment_reference_channel)
[docs]
def has_tiles(self, channel: Optional[str] = None) -> bool:
# extension = '.npy' if self.use_npy() else None
# return len(clearmap_io.file_list(self.filename(channel, sample_id=self.prefix, extension=extension)))
# noinspection PyTypeChecker
if channel is None:
return bool(self.stitchable_channels)
return self.get('raw', channel=channel, sample_id=self.prefix).n_tiles_present > 1
[docs]
def check_has_all_tiles(self, channel: str) -> bool:
"""
Check whether all the tiles of the channel exist on disk
Parameters
----------
channel : str
The channel to check
Returns
-------
bool
True if all the tiles exist
"""
extension = '.npy' if self.use_npy(channel) else None
return self.get('raw', channel, extension=extension).exists
@property
@adjuster_safe
def stitchable_channels(self) -> list[str]:
return self.get_stitchable_channels()
[docs]
def get_stitchable_channels(self) -> list[str]:
candidates = list((self.config.get('channels') or {}).keys())
stitchable = []
for c in candidates:
try:
asset = self.get('raw', channel=c, sample_id=self.prefix)
if asset.is_tiled:
stitchable.append(c)
except KeyError:
continue # channel not set up yet
if not stitchable:
warnings.warn(f'Trying to get stitchable channels before raw data is set up')
return stitchable
[docs]
def can_convert(self, channel: str) -> bool:
asset = self.get('raw', channel=channel, sample_id=self.prefix)
return asset.is_regular_file and not asset.variant(extension='.npy').exists
@property
def channels_to_convert(self) -> list[str]:
candidates = self.config['channels'].keys()
return [c for c in candidates if self.can_convert(c)]
[docs]
def has_npy(self, channel: Optional[str] = None) -> bool:
"""
Check if the channel is in npy format
Parameters
----------
channel : str
The channel to check
Returns
-------
bool
True if the raw channel is in npy format
"""
channels = [channel] if channel is not None else self.stitchable_channels
return any([self.get('raw', channel=channel, sample_id=self.prefix).variant(extension='.npy').exists
for channel in channels])
[docs]
def use_npy(self, channel: str) -> bool:
asset = self.get('raw', channel=channel, sample_id=self.prefix)
cfg = self.cfg_coordinator.get_config_view('stitching')['channels'][channel]
return cfg['use_npy'] and str(asset.expression).endswith('.npy') or asset.variant(extension='.npy').exists
@property
@adjuster_safe
def alignment_reference_channel(self) -> Optional[str]:
try:
return self.get_channels_by_type('autofluorescence') or None
except KeyError:
return None
[docs]
def delete_resampled_files(self, channel: str):
asset = self.get('resampled', channel=channel)
if asset.exists:
asset.delete()
[docs]
def get_channel_resolution(self, channel: str) -> tuple[float, float, float]:
"""
Get the resolution of the channel as defined in the sample config.
Parameters
----------
channel : str
The channel to get the resolution for
Returns
-------
tuple(float, float, float)
The resolution of the channel in (x, y, z) format
"""
return tuple(self.config['channels'][channel]['resolution'])
[docs]
def stitched_shape(self, channel: str) -> tuple[int, int, int]:
asset = self.get('stitched', channel=channel, sample_id=self.prefix)
if asset.exists:
return asset.shape()
elif self.resampled_shape(channel) is not None:
reg_cfg = self.registration_config
raw_resampled_res_from_cfg = np.array(reg_cfg['channels'][channel]['resampled_resolution'])
raw_res_from_cfg = np.array(self.config['channels'][channel]['resolution'])
return self.resampled_shape(channel) * (raw_resampled_res_from_cfg / raw_res_from_cfg)
else:
raise FileNotFoundError(f'Could not get stitched shape without '
f'stitched or resampled file for channel {channel}')
[docs]
def resampled_shape(self, channel: str) -> Optional[tuple[int, int, int]]:
asset = self.workspace.get('resampled', channel=channel, sample_id=self.prefix)
if asset.exists:
return asset.shape()
[docs]
def needs_registering(self, registration_processor: "RegistrationProcessor") -> bool:
status = registration_processor.registration_status()
from ClearMap.pipeline_orchestrators.registration_orchestrator import RegistrationStatus
return status == RegistrationStatus.MISSING_OUTPUTS
[docs]
def get_channels_by_condition(self, condition: Callable, missing_action: str = 'ignore',
multiple_found_action: str ='ignore',
as_list: bool = False, error_label: str = 'channel') -> str | list[str]:
"""
Get the channel or list of channels that satisfy a given condition.
The condition is specified as a function that takes a channel config and returns a boolean.
e.g. to get the channel of type 'autofluorescence':
get_channels_by_condition(lambda cfg: cfg['data_type'] == 'autofluorescence')
Parameters
----------
condition: function
A function that takes a channel config and returns a boolean.
missing_action: str
What to do in case no matching channel is found. One of ['warn', 'raise', 'ignore']
multiple_found_action: str
What to do in case multiple matching channels are found. One of ['warn', 'raise', 'ignore']
as_list: bool
Whether to return the result as a list if a single channel is found.
error_label: str
The label to use in error messages.
Returns
-------
str | List[str]
The channel name or list of channels that match the condition.
Raises
------
KeyError
If no channel is found and missing_action is 'raise'
If multiple channels are found and multiple_found_action is 'raise'
ValueError
If an unknown action (missing_action or multiple_found_action) is specified
"""
channels_cfg = self.config.get('channels') or {}
filtered = []
for chan, cfg in channels_cfg.items():
if not cfg:
continue # skip channels with no config (e.g. from incomplete channel list)
try:
if condition(cfg):
filtered.append(chan)
except KeyError:
continue # incomplete channel config — skip silently
count = len(filtered)
if count == 0:
match missing_action.lower():
case 'ignore':
return [] if as_list else ""
case 'warn':
warnings.warn(f'No {error_label} found')
return [] if as_list else ""
case 'raise':
raise KeyError(f'No {error_label} found')
case _:
raise ValueError(f'Unknown missing action {missing_action}')
elif count > 1:
match multiple_found_action.lower():
case 'ignore':
return filtered
case 'warn':
warnings.warn(f'Multiple {error_label}s found')
return filtered
case 'raise':
raise KeyError(f'Multiple {error_label}s found')
case _:
raise ValueError(f'Unknown multiple found action {multiple_found_action}')
else: # count == 1
result = filtered if as_list else filtered[0]
return result
[docs]
@adjuster_safe
def get_channels_by_type(self, channel_type: str, missing_action: str = 'warn',
multiple_found_action: str ='ignore', as_list: bool = False) -> str | list[str]:
"""
Get the channel or list of channels that are of a given type.
Parameters
----------
channel_type: str
Type of the channel as defined in asset_constants
missing_action: str
What to do in case the channel specified is not found. One of ['warn', 'raise', 'ignore']
multiple_found_action: str
What to do in case multiple matching channels are found. One of ['warn', 'raise', 'ignore']
as_list: bool
Whether to return the result as a list if a single channel is found.
Returns
-------
str | List[str]
The channel name or list of channels that match the type.
Raises
------
KeyError
If no channel is found and missing_action is 'raise'
If multiple channels are found and multiple_found_action is 'raise'
ValueError
If an unknown action (missing_action or multiple_found_action) is specified
"""
return self.get_channels_by_condition(
condition=lambda cfg: cfg.get('data_type') == channel_type,
missing_action=missing_action,
multiple_found_action=multiple_found_action,
as_list=as_list,
error_label=channel_type
)
[docs]
@adjuster_safe
def get_channels_by_pipeline(self, pipeline_name: str, missing_action: str = 'ignore',
multiple_found_action: str ='ignore', as_list: bool = False) -> str | list[str]:
"""
Get the channels that are relevant for a given pipeline
Parameters
----------
pipeline_name : str
The name of the pipeline
missing_action : str
What to do if no channel is found
'ignore' : ignore and return empty list
'warn' : warn and return empty list
'raise' : raise an error
multiple_found_action : str
What to do if multiple channels are found
'ignore' : ignore and return all channels
'warn' : warn and return all channels
'raise' : raise an error
as_list: bool
Whether to return the result as a list if a single channel is found.
Returns
-------
List[str]
The channels that are relevant for the pipeline
Raises
------
KeyError
If no channel is found and missing_action is 'raise'
If multiple channels are found and multiple_found_action is 'raise'
ValueError
If an unknown action (missing_action or multiple_found_action) is specified
"""
if pipeline_name == 'stitching':
chs = self.stitchable_channels
return chs if as_list or len(chs) != 1 else chs[0]
if pipeline_name not in CONTENT_TYPE_TO_PIPELINE.values():
raise ValueError(f'Unknown pipeline name {pipeline_name}. '
f'Options are: {list(CONTENT_TYPE_TO_PIPELINE.values())}')
return self.get_channels_by_condition(
condition=lambda cfg: CONTENT_TYPE_TO_PIPELINE.get(cfg.get('data_type')) == pipeline_name,
missing_action=missing_action, multiple_found_action=multiple_found_action,
as_list=as_list, error_label=pipeline_name
)
# TODO: check if we really need instance_kind here
[docs]
def get_instance_keys_by_pipeline(self, pipeline_name: str, *, instance_kind: str,
oriented: bool = False) -> list[str]:
if pipeline_name.lower() == 'colocalization' and instance_kind == 'pairs':
return self.colocalization_pair_keys(oriented=oriented)
raise ValueError(f'Unsupported pipeline/instance kind combination: {pipeline_name}/{instance_kind}')
[docs]
def compute_relevant_pipelines(self) -> set[str]:
"""
Infer active *per-sample* pipelines based purely on this sample’s channels / types.
Does not know/care about group/batch.
"""
pipelines: set[str] = set()
# 1) channel content types → pipelines
for ch in self.channels:
ct = self.data_type(ch)
p = CONTENT_TYPE_TO_PIPELINE.get(ct)
if p:
pipelines.add(p)
# 2) stitching: as soon as any tiled/pattern channel exists
if self.stitchable_channels:
pipelines.add('stitching')
# 3) registration: if registration is actually meaningful
# FIXME: check if we have an atlas or something
pipelines.add('registration')
# 4) compound/co-loc
if self.is_colocalization_compatible:
pipelines.add('Colocalization')
return pipelines
[docs]
def asset_names_to_assets(self, asset_names: List[str], channel: Optional[str] = None,
sample_id: Optional[str] = None) -> List["WorkspaceAsset"]:
return [self.workspace.get(asset_name) for asset_name in asset_names]
[docs]
@staticmethod
def compress(assets: List["WorkspaceAsset"], format: Optional[str] = None):
for asset in assets:
asset.compress(algorithm=format)
[docs]
@staticmethod
def decompress(assets: List["WorkspaceAsset"], check: bool = True):
for asset in assets:
asset.decompress(check=check)
[docs]
@staticmethod
def plot(assets: List["WorkspaceAsset"], **kwargs): # FIXME: what if len(assets) > 1 ? Should plot together
for asset in assets:
asset.plot(**kwargs)
[docs]
@staticmethod
def convert(assets: List["WorkspaceAsset"], new_extension: str, processes: Optional[int] = None,
verbose: bool = False, **kwargs):
for asset in assets:
asset.convert(new_extension, processes=processes, verbose=verbose, **kwargs)
[docs]
@staticmethod
def resample(assets: List["WorkspaceAsset"], x_scale: float = 1, y_scale: float = 1, z_scale: float =1,
x_resolution=None, y_resolution=None, z_resolution=None,
x_shape=None, y_shape=None, z_shape=None,
orientation=None, # TODO: add orientation
processes=None, verbose=False, **kwargs):
resolution_params = {
'x_scale': x_scale,
'y_scale': y_scale,
'z_scale': z_scale,
'x_shape': x_shape,
'y_shape': y_shape,
'z_shape': z_shape,
'x_resolution': x_resolution,
'y_resolution': y_resolution,
'z_resolution': z_resolution
}
resolution_params = {k: v for k, v in resolution_params.items() if v not in (1, None)}
for asset in assets:
if 'x_shape' in resolution_params.keys():
resampling_params = {
'original_shape': asset.shape(),
'resampled_shape': tuple([resolution_params[f'{ax}_shape'] for ax in 'xyz'])}
elif 'x_resolution' in resolution_params.keys():
resampling_params = {
'resampled_resolution': tuple([resolution_params[f'{ax}_resolution'] for ax in 'xyz'])}
# FIXME: needs original resolution
# resampling_params = {'original_resolution': ...}
elif 'x_scale' in resolution_params.keys():
original_shape = {ax: s for ax, s in zip('xyz', asset.shape())}
resampling_params = {f'{ax}_shape': original_shape[ax] // resolution_params[f'{ax}_scale']
for ax in 'xyz'}
resampled_path = asset.path.with_suffix(f'.resampled.{asset.path.suffix}')
resampling.resample(original=str(asset.path), resampled=resampled_path,
**resampling_params, orientation=orientation,
processes=processes, verbose=verbose, **kwargs)
def _make_bootstrap_dir() -> Path:
"""
Create a temporary directory for this session to hold config files
until the experiment folder is set by the user.
We prefer a user-configured temp if available (CLEARMAP_TMP env var)
otherwise use the system temp folder.
The folder is named "clearmap_bootstrap-<username>/session_<pid>" to avoid clashes
if multiple instances are running.
The folder is removed on exit if it stayed unused (i.e. still a bootstrap dir).
Returns
-------
Path
The path to the temporary bootstrap directory
"""
root = Path(os.environ.get('CLEARMAP_TMP', tempfile.gettempdir()))
session = root / f'clearmap_bootstrap-{getpass.getuser()}' / f'session_{os.getpid()}'
session.mkdir(parents=True, exist_ok=True)
return session
[docs]
def build_sample_manager(src_dir='', bus: Optional[EventBus] = None):
bootstrap_dir = _make_bootstrap_dir()
@atexit.register
def _cleanup_bootstrap():
try:
if bootstrap_dir.name.startswith('session_'):
shutil.rmtree(bootstrap_dir, ignore_errors=True)
except Exception:
pass
if bus is None:
bus = EventBus()
cfg_coordinator_factory = make_cfg_coordinator_factory(bus)
cfg_coordinator = cfg_coordinator_factory(bootstrap_dir,
config_groups=(ALTERNATIVES_REG._pipeline_groups,
ALTERNATIVES_REG._global_groups))
cfg_coordinator.seed_missing_from_defaults(tabs_only=True)
sample_manager = SampleManager(config_coordinator=cfg_coordinator, src_dir=None)
if src_dir:
sample_manager.setup(src_dir)
return sample_manager
[docs]
def check_protocol_coverage(cls, protocol_cls):
"""
Check if all the decorated methods of SampleManager (meant to be used in SampleManagerProtocol)
are present and decorated in the given class.
Raises TypeError if any protocol member lacks the marker.
"""
# Python 3.12+ has __protocol_attrs__; fall back to __annotations__
names = getattr(protocol_cls, '__protocol_attrs__', None)
if names is None:
names = {n for n in dir(protocol_cls) if not n.startswith('_') and n not in dir(object)}
missing = []
for name in names:
raw = getattr(cls, name, None)
if raw is None:
missing.append(f'{name} (not found)')
continue
fn = raw.fget if isinstance(raw, property) else raw
if not getattr(fn, '_adjuster_safe', False):
missing.append(name)
if missing:
raise TypeError(f'{cls.__name__} protocol members missing @adjuster_safe: {missing}')
check_protocol_coverage(SampleManager, SampleManagerProtocol)