diff --git a/.github/workflows/test_oncall.yml b/.github/workflows/test_oncall.yml index 10ca1af..c5724e7 100644 --- a/.github/workflows/test_oncall.yml +++ b/.github/workflows/test_oncall.yml @@ -30,8 +30,32 @@ jobs: with: python-version: '3.10' - # these libraries enable testing on Qt on linux - # - uses: tlambert03/setup-qt-libs@v1 # TODO CHECK IF NEEDED + # these libraries enable testing Qt on linux. The self-hosted Docker + # runner does not include sudo, so install directly when running as root. + - name: Install Qt runtime libraries + run: | + if command -v sudo >/dev/null 2>&1; then + APT_GET="sudo apt-get" + else + APT_GET="apt-get" + fi + + $APT_GET update + DEBIAN_FRONTEND=noninteractive $APT_GET install -y --no-install-recommends \ + libdbus-1-3 \ + libegl1 \ + libgl1 \ + libopengl0 \ + libxcb-cursor0 \ + libxcb-icccm4 \ + libxcb-image0 \ + libxcb-keysyms1 \ + libxcb-render-util0 \ + libxcb-shape0 \ + libxcb-xfixes0 \ + libxcb-xinerama0 \ + libxcb-xinput0 \ + libxkbcommon-x11-0 # note: if you need dependencies from conda, considering using # setup-miniconda: https://github.com/conda-incubator/setup-miniconda diff --git a/setup.cfg b/setup.cfg index 727ecde..05a9ae1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = napari-mAIcrobe -version = 0.0.5 +version = 0.0.6 description = mAIcrobe long_description = file: README.md long_description_content_type = text/markdown diff --git a/src/napari_mAIcrobe/_batchanalysis.py b/src/napari_mAIcrobe/_batchanalysis.py new file mode 100644 index 0000000..573805b --- /dev/null +++ b/src/napari_mAIcrobe/_batchanalysis.py @@ -0,0 +1,658 @@ +"""Batch FoV segmentation and analysis widget and utilities.""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from fnmatch import fnmatch +from pathlib import Path +from typing import TYPE_CHECKING + +import pandas as pd +from magicgui import magic_factory +from skimage.io import imread, imsave + +from .mAIcrobe.cells import CellManager +from .mAIcrobe.segmentation import ( + cellpose_segmentation, + classical_segmentation, + stardist_segmentation, + unet_segmentation, +) + +if TYPE_CHECKING: + import napari + + +@dataclass +class FoVMapping: + """Resolved channel files for one FoV directory.""" + + name: str + folder: Path + base_file: Path + membrane_file: Path + dna_file: Path | None + + +def _is_tiff(path: Path) -> bool: + return path.suffix.lower() in {".tif", ".tiff"} + + +def _list_tiff_files(folder: Path) -> list[Path]: + return sorted( + (p for p in folder.iterdir() if p.is_file() and _is_tiff(p)), + key=lambda p: p.name.lower(), + ) + + +def discover_fov_directories(input_root: os.PathLike | str) -> list[Path]: + """Return direct child folders containing at least one TIFF file.""" + + root = Path(input_root) + if not root.exists() or not root.is_dir(): + raise ValueError(f"Input root does not exist or is not a dir: {root}") + + fov_dirs = [] + for child in sorted(root.iterdir(), key=lambda p: p.name.lower()): + if not child.is_dir(): + continue + if len(_list_tiff_files(child)) > 0: + fov_dirs.append(child) + + return fov_dirs + + +def _single_pattern_match( + files: list[Path], + pattern: str, + role_name: str, + required: bool, +) -> Path | None: + if pattern.strip() == "": + if required: + raise ValueError(f"Pattern for {role_name} cannot be empty") + return None + + lowered_pattern = pattern.lower() + matches = [ + file_path + for file_path in files + if fnmatch(file_path.name.lower(), lowered_pattern) + ] + + if len(matches) == 0: + if required: + raise ValueError( + f"No file matched {role_name} pattern '{pattern}'" + ) + return None + + if len(matches) > 1: + match_names = ", ".join(m.name for m in matches) + raise ValueError( + f"Ambiguous {role_name} pattern '{pattern}'. Matches: {match_names}" + ) + + return matches[0] + + +def map_fov_files( + fov_dir: os.PathLike | str, + base_pattern: str, + membrane_pattern: str, + dna_pattern: str, +) -> FoVMapping: + """Resolve base/membrane/dna files in one FoV folder by glob patterns.""" + + folder = Path(fov_dir) + tif_files = _list_tiff_files(folder) + + if len(tif_files) == 0: + raise ValueError(f"No TIFF files found in {folder}") + + base_file = _single_pattern_match( + tif_files, + pattern=base_pattern, + role_name="base", + required=True, + ) + membrane_file = _single_pattern_match( + tif_files, + pattern=membrane_pattern, + role_name="membrane", + required=True, + ) + dna_file = _single_pattern_match( + tif_files, + pattern=dna_pattern, + role_name="dna", + required=False, + ) + + return FoVMapping( + name=folder.name, + folder=folder, + base_file=base_file, + membrane_file=membrane_file, + dna_file=dna_file, + ) + + +def _safe_report_id(name: str) -> str: + sanitized = re.sub(r"[^0-9A-Za-z_-]+", "_", name).strip("_") + return sanitized if sanitized else "fov" + + +def _validate_2d(name: str, image) -> None: + if image.ndim != 2: + raise ValueError(f"{name} image must be 2D. Got shape {image.shape}") + + +def _segment_single_fov( + base_image, + segmentation_algorithm: str, + binary_closing: int, + binary_dilation: int, + binary_fillholes: bool, + la_blocksize: int, + la_offset: float, + watershed_pars: dict, + unet_model_type: str, + unet_pretrained: str, + unet_model_path: os.PathLike | str, + stardist_model_type: str, + stardist_pretrained: str, + stardist_model_path: os.PathLike | str, +): + if segmentation_algorithm == "Unet": + return unet_segmentation( + base_image, + unet_model_type, + unet_pretrained, + str(unet_model_path), + binary_closing, + binary_dilation, + binary_fillholes, + ) + + if segmentation_algorithm == "StarDist": + return stardist_segmentation( + base_image, + stardist_model_type, + stardist_pretrained, + str(stardist_model_path), + ) + + if segmentation_algorithm == "CellPose cyto3": + return cellpose_segmentation(base_image) + + return classical_segmentation( + base_image, + segmentation_algorithm, + la_blocksize, + la_offset, + binary_closing, + binary_dilation, + binary_fillholes, + watershed_pars, + ) + + +def _cellmanager_params( + pixel_size: float, + inner_mask_thickness: int, + septum_algorithm: str, + baseline_margin: int, + find_septum: bool, + find_open_septum: bool, + classify_cell_cycle: bool, + model: str, + custom_model_path: os.PathLike | str, + custom_model_input: str, + custom_model_maxsize: int, + compute_colocalization: bool, + generate_report: bool, + report_path: Path, + report_id: str, +): + return { + "pixel_size": pixel_size, + "inner_mask_thickness": inner_mask_thickness, + "septum_algorithm": septum_algorithm, + "baseline_margin": baseline_margin, + "find_septum": find_septum, + "find_openseptum": find_open_septum, + "classify_cell_cycle": classify_cell_cycle, + "model": model, + "custom_model_path": str(custom_model_path), + "custom_model_input": custom_model_input, + "custom_model_maxsize": custom_model_maxsize, + "generate_report": generate_report, + "report_path": str(report_path), + "report_id": report_id, + "cell_averager": False, + "coloc": compute_colocalization, + } + + +def run_batch_analysis( + input_root: os.PathLike | str, + output_root: os.PathLike | str, + base_pattern: str, + membrane_pattern: str, + dna_pattern: str, + segmentation_algorithm: str, + binary_closing: int, + binary_dilation: int, + binary_fillholes: bool, + la_blocksize: int, + la_offset: float, + peak_min_distance_from_edge: int, + peak_min_distance: int, + peak_min_height: int, + max_peaks: int, + unet_model_type: str, + unet_pretrained: str, + unet_model_path: os.PathLike | str, + stardist_model_type: str, + stardist_pretrained: str, + stardist_model_path: os.PathLike | str, + pixel_size: float, + inner_mask_thickness: int, + septum_algorithm: str, + baseline_margin: int, + find_septum: bool, + find_open_septum: bool, + classify_cell_cycle: bool, + model: str, + custom_model_path: os.PathLike | str, + custom_model_input: str, + custom_model_maxsize: int, + compute_colocalization: bool, + generate_per_fov_report: bool, + save_segmentation_tifs: bool, + save_merged_csv: bool, + continue_on_error: bool, +) -> dict: + """Run segmentation and per-cell analysis for all FoV folders.""" + + output_root_path = Path(output_root) + output_root_path.mkdir(parents=True, exist_ok=True) + + watershed_pars = { + "peak_min_distance_from_edge": peak_min_distance_from_edge, + "peak_min_distance": peak_min_distance, + "peak_min_height": peak_min_height, + "max_peaks": max_peaks, + } + + fov_dirs = discover_fov_directories(input_root) + if len(fov_dirs) == 0: + raise ValueError("No FoV folders with TIFF files were found") + + merged_rows = [] + errors = [] + success_count = 0 + + last_mask = None + last_labels = None + + for index, fov_dir in enumerate(fov_dirs, start=1): + fov_name = fov_dir.name + print(f"[{index}/{len(fov_dirs)}] Processing {fov_name}") + fov_output = output_root_path / fov_name + fov_output.mkdir(parents=True, exist_ok=True) + + try: + mapping = map_fov_files( + fov_dir, + base_pattern=base_pattern, + membrane_pattern=membrane_pattern, + dna_pattern=dna_pattern, + ) + + base_image = imread(str(mapping.base_file)) + membrane_image = imread(str(mapping.membrane_file)) + dna_image = ( + imread(str(mapping.dna_file)) + if mapping.dna_file is not None + else None + ) + + _validate_2d("Base", base_image) + _validate_2d("Membrane", membrane_image) + if dna_image is not None: + _validate_2d("DNA", dna_image) + + mask, labels = _segment_single_fov( + base_image=base_image, + segmentation_algorithm=segmentation_algorithm, + binary_closing=binary_closing, + binary_dilation=binary_dilation, + binary_fillholes=binary_fillholes, + la_blocksize=la_blocksize, + la_offset=la_offset, + watershed_pars=watershed_pars, + unet_model_type=unet_model_type, + unet_pretrained=unet_pretrained, + unet_model_path=unet_model_path, + stardist_model_type=stardist_model_type, + stardist_pretrained=stardist_pretrained, + stardist_model_path=stardist_model_path, + ) + + if save_segmentation_tifs: + imsave( + str(fov_output / "mask.tif"), + mask.astype("uint16"), + check_contrast=False, + ) + imsave( + str(fov_output / "labels.tif"), + labels.astype("uint16"), + check_contrast=False, + ) + + params = _cellmanager_params( + pixel_size=pixel_size, + inner_mask_thickness=inner_mask_thickness, + septum_algorithm=septum_algorithm, + baseline_margin=baseline_margin, + find_septum=find_septum, + find_open_septum=find_open_septum, + classify_cell_cycle=classify_cell_cycle, + model=model, + custom_model_path=custom_model_path, + custom_model_input=custom_model_input, + custom_model_maxsize=custom_model_maxsize, + compute_colocalization=compute_colocalization, + generate_report=generate_per_fov_report, + report_path=fov_output, + report_id=_safe_report_id(fov_name), + ) + + cell_man = CellManager( + label_img=labels, + fluor=membrane_image, + optional=dna_image, + params=params, + ) + cell_man.compute_cell_properties() + + fov_df = pd.DataFrame(cell_man.properties) + fov_df.insert(0, "fov_path", str(fov_dir)) + fov_df.insert(0, "fov_name", fov_name) + merged_rows.append(fov_df) + + success_count += 1 + last_mask = mask + last_labels = labels + + except Exception as exc: + errors.append( + { + "fov_name": fov_name, + "fov_path": str(fov_dir), + "error": str(exc), + } + ) + print(f"Failed {fov_name}: {exc}") + if not continue_on_error: + raise + + merged_csv_path = output_root_path / "batch_merged_analysis.csv" + if save_merged_csv: + if len(merged_rows) > 0: + pd.concat(merged_rows, ignore_index=True).to_csv( + merged_csv_path, index=False + ) + else: + pd.DataFrame(columns=["fov_name", "fov_path"]).to_csv( + merged_csv_path, index=False + ) + + errors_csv_path = output_root_path / "batch_errors.csv" + pd.DataFrame(errors).to_csv(errors_csv_path, index=False) + + summary = { + "total_fovs": len(fov_dirs), + "success_fovs": success_count, + "failed_fovs": len(errors), + "merged_csv": str(merged_csv_path), + "errors_csv": str(errors_csv_path), + "last_mask": last_mask, + "last_labels": last_labels, + } + return summary + + +def _update_segmentation_visibility(gui) -> None: + """Show only controls needed for the selected segmentation algorithm.""" + + algorithm = gui.Segmentation_algorithm.value + + is_unet = algorithm == "Unet" + is_stardist = algorithm == "StarDist" + is_classical = algorithm in {"Isodata", "Local Average"} + + show_binary_ops = algorithm in { + "Isodata", + "Local Average", + "Unet", + } + gui.Binary_closing.visible = show_binary_ops + gui.Binary_dilation.visible = show_binary_ops + gui.Binary_fillholes.visible = show_binary_ops + + gui.LA_blocksize.visible = algorithm == "Local Average" + gui.LA_offset.visible = algorithm == "Local Average" + + gui.Peak_min_distance_from_edge.visible = is_classical + gui.Peak_min_distance.visible = is_classical + gui.Peak_min_height.visible = is_classical + gui.Max_peaks.visible = is_classical + + gui.Unet_model_type.visible = is_unet + gui.Unet_pretrained.visible = ( + is_unet and gui.Unet_model_type.value == "Pretrained" + ) + gui.Unet_model_path.visible = ( + is_unet and gui.Unet_model_type.value == "Custom" + ) + + gui.StarDist_model_type.visible = is_stardist + gui.StarDist_pretrained.visible = ( + is_stardist and gui.StarDist_model_type.value == "Pretrained" + ) + gui.StarDist_model_path.visible = ( + is_stardist and gui.StarDist_model_type.value == "Custom" + ) + + +def _update_model_visibility(gui) -> None: + """Show analysis/classifier controls according to Advanced mode.""" + + advanced_mode = gui.Advanced_mode.value + classify_cell_cycle = gui.Classify_cell_cycle.value + + gui.Pixel_size.visible = advanced_mode + gui.Inner_mask_thickness.visible = advanced_mode + gui.Septum_algorithm.visible = advanced_mode + gui.Baseline_margin.visible = advanced_mode + gui.Find_septum.visible = advanced_mode + gui.Find_open_septum.visible = advanced_mode + + gui.Compute_Colocalization.visible = True + gui.Generate_per_fov_report.visible = advanced_mode + gui.Save_segmentation_tifs.visible = advanced_mode + gui.Save_merged_csv.visible = advanced_mode + gui.Continue_on_error.visible = advanced_mode + + gui.Model.visible = classify_cell_cycle + is_custom_model = gui.Model.value == "custom" + gui.Custom_model_path.visible = classify_cell_cycle and is_custom_model + gui.Custom_model_input.visible = classify_cell_cycle and is_custom_model + gui.Custom_model_MaxSize.visible = classify_cell_cycle and is_custom_model + + +def _init_batch_widget(gui) -> None: + """Connect UI signals and initialize field visibility.""" + + gui.Advanced_mode.changed.connect( + lambda _value: _update_segmentation_visibility(gui) + ) + gui.Advanced_mode.changed.connect( + lambda _value: _update_model_visibility(gui) + ) + gui.Segmentation_algorithm.changed.connect( + lambda _value: _update_segmentation_visibility(gui) + ) + gui.Unet_model_type.changed.connect( + lambda _value: _update_segmentation_visibility(gui) + ) + gui.StarDist_model_type.changed.connect( + lambda _value: _update_segmentation_visibility(gui) + ) + gui.Classify_cell_cycle.changed.connect( + lambda _value: _update_model_visibility(gui) + ) + gui.Model.changed.connect(lambda _value: _update_model_visibility(gui)) + + _update_segmentation_visibility(gui) + _update_model_visibility(gui) + + +@magic_factory( + widget_init=_init_batch_widget, + Input_root={"widget_type": "FileEdit", "mode": "d"}, + Output_root={"widget_type": "FileEdit", "mode": "d"}, + Segmentation_algorithm={ + "choices": [ + "Isodata", + "Local Average", + "Unet", + "StarDist", + "CellPose cyto3", + ] + }, + Unet_model_type={"choices": ["Pretrained", "Custom"]}, + Unet_pretrained={ + "choices": [ + "Ph.C. S. pneumo", + "WF FtsZ B. subtilis", + "Unet S. aureus", + ] + }, + Unet_model_path={"widget_type": "FileEdit", "mode": "r"}, + StarDist_model_type={"choices": ["Pretrained", "Custom"]}, + StarDist_pretrained={"choices": ["StarDist S. aureus"]}, + StarDist_model_path={"widget_type": "FileEdit", "mode": "d"}, + Septum_algorithm={"choices": ["Isodata", "Box"]}, + Model={ + "choices": [ + "S.aureus DNA+Membrane Epi", + "S.aureus DNA+Membrane SIM", + "S.aureus DNA Epi", + "S.aureus DNA SIM", + "S.aureus Membrane Epi", + "S.aureus Membrane SIM", + "E.coli DNA+Membrane AB phenotyping", + "custom", + ] + }, + Custom_model_path={"widget_type": "FileEdit", "mode": "r"}, + Custom_model_input={"choices": ["Membrane", "DNA", "Membrane+DNA"]}, +) +def batch_analysis( + Viewer: napari.Viewer, + Input_root: os.PathLike = "", + Output_root: os.PathLike = "", + Advanced_mode: bool = False, + Base_pattern: str = "*phase*.tif*", + Membrane_pattern: str = "*mem*.tif*", + DNA_pattern: str = "*dna*.tif*", + Segmentation_algorithm: str = "Isodata", + Binary_closing: int = 0, + Binary_dilation: int = 0, + Binary_fillholes: bool = False, + LA_blocksize: int = 151, + LA_offset: float = 0.02, + Peak_min_distance_from_edge: int = 10, + Peak_min_distance: int = 5, + Peak_min_height: int = 5, + Max_peaks: int = 100000, + Unet_model_type: str = "Pretrained", + Unet_pretrained: str = "Ph.C. S. pneumo", + Unet_model_path: os.PathLike = "", + StarDist_model_type: str = "Pretrained", + StarDist_pretrained: str = "StarDist S. aureus", + StarDist_model_path: os.PathLike = "", + Pixel_size: float = 1.0, + Inner_mask_thickness: int = 4, + Septum_algorithm: str = "Isodata", + Baseline_margin: int = 30, + Find_septum: bool = False, + Find_open_septum: bool = False, + Classify_cell_cycle: bool = False, + Model: str = "S.aureus DNA+Membrane Epi", + Custom_model_path: os.PathLike = "", + Custom_model_input: str = "Membrane", + Custom_model_MaxSize: int = 50, + Compute_Colocalization: bool = False, + Generate_per_fov_report: bool = True, + Save_segmentation_tifs: bool = True, + Save_merged_csv: bool = True, + Continue_on_error: bool = True, +): + """Batch analyze one root folder with one subfolder per FoV.""" + + summary = run_batch_analysis( + input_root=Input_root, + output_root=Output_root, + base_pattern=Base_pattern, + membrane_pattern=Membrane_pattern, + dna_pattern=DNA_pattern, + segmentation_algorithm=Segmentation_algorithm, + binary_closing=Binary_closing, + binary_dilation=Binary_dilation, + binary_fillholes=Binary_fillholes, + la_blocksize=LA_blocksize, + la_offset=LA_offset, + peak_min_distance_from_edge=Peak_min_distance_from_edge, + peak_min_distance=Peak_min_distance, + peak_min_height=Peak_min_height, + max_peaks=Max_peaks, + unet_model_type=Unet_model_type, + unet_pretrained=Unet_pretrained, + unet_model_path=Unet_model_path, + stardist_model_type=StarDist_model_type, + stardist_pretrained=StarDist_pretrained, + stardist_model_path=StarDist_model_path, + pixel_size=Pixel_size, + inner_mask_thickness=Inner_mask_thickness, + septum_algorithm=Septum_algorithm, + baseline_margin=Baseline_margin, + find_septum=Find_septum, + find_open_septum=Find_open_septum, + classify_cell_cycle=Classify_cell_cycle, + model=Model, + custom_model_path=Custom_model_path, + custom_model_input=Custom_model_input, + custom_model_maxsize=Custom_model_MaxSize, + compute_colocalization=Compute_Colocalization, + generate_per_fov_report=Generate_per_fov_report, + save_segmentation_tifs=Save_segmentation_tifs, + save_merged_csv=Save_merged_csv, + continue_on_error=Continue_on_error, + ) + + print( + "Batch finished. " + f"Total={summary['total_fovs']} " + f"Success={summary['success_fovs']} " + f"Failed={summary['failed_fovs']}" + ) + print(f"Merged CSV: {summary['merged_csv']}") + print(f"Errors CSV: {summary['errors_csv']}") diff --git a/src/napari_mAIcrobe/_computecells.py b/src/napari_mAIcrobe/_computecells.py index 7881747..893de91 100644 --- a/src/napari_mAIcrobe/_computecells.py +++ b/src/napari_mAIcrobe/_computecells.py @@ -35,7 +35,7 @@ def compute_cells( Viewer: "napari.Viewer", Label_Image: "napari.layers.Labels", Membrane_Image: "napari.layers.Image", - DNA_Image: "napari.layers.Image", + DNA_Image: "napari.layers.Image" = None, Pixel_size: float = 1, Inner_mask_thickness: int = 4, Septum_algorithm="Isodata", @@ -52,11 +52,12 @@ def compute_cells( Report_path: os.PathLike = "", Compute_Heatmap: bool = False, ): - """Compute per-cell features, generate reports. Optionally build - average heatmap, classification and colocalization. + """Compute per-cell morphological features, classification and optional reports from 2D images or + timelapse 2D+t data. Additionally supports optional heatmap generation for 2D inputs. - #TODO check parameter order in the GUI and in the docstring. It - should make sense to the user. + Supports 2D inputs `(Y, X)` and timelapse inputs `(T, Y, X)`. In + timelapse mode, each frame is analyzed independently (NO TRACKING), + and results are aggregated with a `frame` column. Parameters ---------- @@ -66,8 +67,10 @@ def compute_cells( Labels layer with segmented cells. Membrane_Image : napari.layers.Image Primary fluorescence image (e.g., membrane). - DNA_Image : napari.layers.Image - Optional fluorescence image (e.g., DNA). + DNA_Image : napari.layers.Image, optional + Optional secondary fluorescence image (e.g., DNA). If omitted, + DNA-dependent metrics are NaN, colocalization is + skipped and classification is limited to one channel. Pixel_size : float, optional Pixel size passed to analysis (if used downstream), by default 1. Inner_mask_thickness : int, optional @@ -104,10 +107,14 @@ def compute_cells( Notes ----- - - Updates `Label_Image.properties` and opens a properties table. - - Adds "Cell Averager" image if heatmap is computed. + - In 2D mode, updates `Label_Image.properties` and opens a + properties table. + - In timelapse mode, skips table attachment and processes all + frames into one combined output. + - Adds "Cell Averager" image if heatmap is computed (2D mode only). - Saves report files if requested and path is valid. - - Colocalization requires two channels. + - Colocalization requires two channels and is skipped when + `DNA_Image` is not provided. - Custom model requires a valid Keras model file (.keras) """ @@ -129,17 +136,49 @@ def compute_cells( "coloc": Compute_Colocalization, } + label_data = Label_Image.data + membrane_data = Membrane_Image.data + dna_data = DNA_Image.data if DNA_Image is not None else None + + if label_data.ndim not in (2, 3): + raise ValueError("Label image must be 2D or 3D (T, Y, X).") + + if membrane_data.ndim != label_data.ndim: + raise ValueError( + "Label and membrane images must have matching dimensions." + ) + + if membrane_data.shape != label_data.shape: + raise ValueError( + "Label and membrane images must have matching shapes." + ) + + if dna_data is not None: + if dna_data.ndim != label_data.ndim: + raise ValueError( + "Optional image must have matching dimensions with label image." + ) + if dna_data.shape != label_data.shape: + raise ValueError( + "Optional image must have matching shape with label image." + ) + cell_man = CellManager( - label_img=Label_Image.data, - fluor=Membrane_Image.data, - optional=DNA_Image.data, + label_img=label_data, + fluor=membrane_data, + optional=dna_data, params=params, ) cell_man.compute_cell_properties() - Label_Image.properties = cell_man.properties - - add_table(Label_Image, Viewer) + if label_data.ndim == 2: + Label_Image.properties = cell_man.properties + add_table(Label_Image, Viewer) + else: + print( + "Timelapse mode detected: skipping napari table attachment; " + "combined results are available in reports/output properties." + ) if Compute_Heatmap: Viewer.add_image(cell_man.heatmap_model, name="Cell Averager") diff --git a/src/napari_mAIcrobe/_tests/analysis_test.py b/src/napari_mAIcrobe/_tests/analysis_test.py index b3c4ba6..214b607 100644 --- a/src/napari_mAIcrobe/_tests/analysis_test.py +++ b/src/napari_mAIcrobe/_tests/analysis_test.py @@ -57,4 +57,87 @@ def test_cellmanager_single_cell(membrane_example, dna_example): assert lbl.sum() == props["Area"][0] +def test_cellmanager_timelapse_combines_frames(membrane_example, dna_example): + h, w = membrane_example.shape + + lbl_t0 = np.zeros((h, w), dtype=np.int32) + lbl_t1 = np.zeros((h, w), dtype=np.int32) + lbl_t0[10:30, 10:30] = 1 + lbl_t1[20:45, 50:75] = 1 + + lbl = np.stack([lbl_t0, lbl_t1], axis=0) + membrane = np.stack([membrane_example, membrane_example], axis=0) + dna = np.stack([dna_example, dna_example], axis=0) + + params = { + "pixel_size": 1.0, + "inner_mask_thickness": 4, + "septum_algorithm": "Isodata", + "baseline_margin": 30, + "find_septum": False, + "find_openseptum": False, + "classify_cell_cycle": False, + "model": "S.aureus DNA+Membrane Epi", + "custom_model_path": "", + "custom_model_input": "Membrane", + "custom_model_maxsize": 50, + "generate_report": False, + "report_path": "", + "cell_averager": False, + "coloc": False, + } + + cm = CellManager( + label_img=lbl, + fluor=membrane, + optional=dna, + params=params, + ) + cm.compute_cell_properties() + + props = cm.properties + assert props is not None + assert "frame" in props + assert len(props["label"]) == 2 + assert set(props["frame"].tolist()) == {0, 1} + + +def test_cellmanager_missing_dna_is_supported(membrane_example): + h, w = membrane_example.shape + lbl = np.zeros((h, w), dtype=np.int32) + lbl[12:44, 18:52] = 1 + + params = { + "pixel_size": 1.0, + "inner_mask_thickness": 4, + "septum_algorithm": "Isodata", + "baseline_margin": 30, + "find_septum": False, + "find_openseptum": False, + "classify_cell_cycle": False, + "model": "S.aureus Membrane Epi", + "custom_model_path": "", + "custom_model_input": "Membrane", + "custom_model_maxsize": 50, + "generate_report": False, + "report_path": "", + "cell_averager": False, + "coloc": True, + } + + cm = CellManager( + label_img=lbl, + fluor=membrane_example, + optional=None, + params=params, + ) + cm.compute_cell_properties() + + props = cm.properties + assert props is not None + assert "DNA Ratio" in props + assert len(props["DNA Ratio"]) == 1 + assert np.isnan(props["DNA Ratio"][0]) + + # Add more tests that cover different parameters and edge cases diff --git a/src/napari_mAIcrobe/_tests/batchanalysis_helpers_test.py b/src/napari_mAIcrobe/_tests/batchanalysis_helpers_test.py new file mode 100644 index 0000000..439992e --- /dev/null +++ b/src/napari_mAIcrobe/_tests/batchanalysis_helpers_test.py @@ -0,0 +1,174 @@ +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest + +from napari_mAIcrobe import _batchanalysis as batch + + +def test_discover_fov_directories_rejects_missing_root(tmp_path): + with pytest.raises(ValueError, match="does not exist"): + batch.discover_fov_directories(tmp_path / "missing") + + +def test_map_fov_files_rejects_empty_required_pattern(tmp_path): + fov = tmp_path / "fov" + fov.mkdir() + (fov / "phase.tif").write_bytes(b"not really a tif") + + with pytest.raises(ValueError, match="cannot be empty"): + batch.map_fov_files(fov, "", "*mem*.tif", "*dna*.tif") + + +def test_map_fov_files_allows_missing_optional_dna(tmp_path): + fov = tmp_path / "fov" + fov.mkdir() + (fov / "phase.tif").write_bytes(b"") + (fov / "mem.tif").write_bytes(b"") + + mapping = batch.map_fov_files(fov, "*phase*.tif", "*mem*.tif", "*dna*.tif") + + assert mapping.name == "fov" + assert mapping.dna_file is None + + +def test_safe_report_id_sanitizes_names(): + assert batch._safe_report_id("FoV 01 / test!") == "FoV_01_test" + assert batch._safe_report_id("!!!") == "fov" + + +def test_validate_2d_rejects_stack(): + with pytest.raises(ValueError, match="must be 2D"): + batch._validate_2d("Base", np.zeros((2, 3, 3))) + + +@pytest.mark.parametrize( + ("algorithm", "expected"), + [ + ("Unet", "unet"), + ("StarDist", "stardist"), + ("CellPose cyto3", "cellpose"), + ("Isodata", "classical"), + ], +) +def test_segment_single_fov_dispatches(monkeypatch, algorithm, expected): + calls = [] + + monkeypatch.setattr( + batch, + "unet_segmentation", + lambda *args: calls.append("unet") or ("mask", "labels"), + ) + monkeypatch.setattr( + batch, + "stardist_segmentation", + lambda *args: calls.append("stardist") or ("mask", "labels"), + ) + monkeypatch.setattr( + batch, + "cellpose_segmentation", + lambda *args: calls.append("cellpose") or ("mask", "labels"), + ) + monkeypatch.setattr( + batch, + "classical_segmentation", + lambda *args: calls.append("classical") or ("mask", "labels"), + ) + + result = batch._segment_single_fov( + base_image=np.zeros((2, 2)), + segmentation_algorithm=algorithm, + binary_closing=0, + binary_dilation=0, + binary_fillholes=False, + la_blocksize=151, + la_offset=0.02, + watershed_pars={}, + unet_model_type="Custom", + unet_pretrained="", + unet_model_path=Path("model.h5"), + stardist_model_type="Custom", + stardist_pretrained="", + stardist_model_path=Path("model"), + ) + + assert calls == [expected] + assert result == ("mask", "labels") + + +def test_cellmanager_params_maps_gui_values_to_internal_keys(tmp_path): + params = batch._cellmanager_params( + pixel_size=0.5, + inner_mask_thickness=3, + septum_algorithm="Box", + baseline_margin=10, + find_septum=True, + find_open_septum=False, + classify_cell_cycle=True, + model="custom", + custom_model_path=Path("model.keras"), + custom_model_input="DNA", + custom_model_maxsize=40, + compute_colocalization=True, + generate_report=True, + report_path=tmp_path, + report_id="fov_1", + ) + + assert params["pixel_size"] == 0.5 + assert params["septum_algorithm"] == "Box" + assert params["custom_model_path"] == "model.keras" + assert params["report_path"] == str(tmp_path) + assert params["report_id"] == "fov_1" + assert params["coloc"] is True + + +def test_update_visibility_helpers_toggle_expected_fields(): + def widget(value=None): + return SimpleNamespace(value=value, visible=None) + + gui = SimpleNamespace( + Segmentation_algorithm=widget("Unet"), + Binary_closing=widget(), + Binary_dilation=widget(), + Binary_fillholes=widget(), + LA_blocksize=widget(), + LA_offset=widget(), + Peak_min_distance_from_edge=widget(), + Peak_min_distance=widget(), + Peak_min_height=widget(), + Max_peaks=widget(), + Unet_model_type=widget("Pretrained"), + Unet_pretrained=widget(), + Unet_model_path=widget(), + StarDist_model_type=widget("Custom"), + StarDist_pretrained=widget(), + StarDist_model_path=widget(), + Advanced_mode=widget(True), + Classify_cell_cycle=widget(True), + Pixel_size=widget(), + Inner_mask_thickness=widget(), + Septum_algorithm=widget(), + Baseline_margin=widget(), + Find_septum=widget(), + Find_open_septum=widget(), + Compute_Colocalization=widget(), + Generate_per_fov_report=widget(), + Save_segmentation_tifs=widget(), + Save_merged_csv=widget(), + Continue_on_error=widget(), + Model=widget("custom"), + Custom_model_path=widget(), + Custom_model_input=widget(), + Custom_model_MaxSize=widget(), + ) + + batch._update_segmentation_visibility(gui) + batch._update_model_visibility(gui) + + assert gui.Unet_pretrained.visible is True + assert gui.Unet_model_path.visible is False + assert gui.Peak_min_distance.visible is False + assert gui.Custom_model_path.visible is True + assert gui.Generate_per_fov_report.visible is True diff --git a/src/napari_mAIcrobe/_tests/batchanalysis_test.py b/src/napari_mAIcrobe/_tests/batchanalysis_test.py new file mode 100644 index 0000000..664a4ac --- /dev/null +++ b/src/napari_mAIcrobe/_tests/batchanalysis_test.py @@ -0,0 +1,142 @@ +from pathlib import Path + +import numpy as np +from skimage.io import imsave + +from napari_mAIcrobe._batchanalysis import ( + discover_fov_directories, + map_fov_files, + run_batch_analysis, +) + + +def _make_test_image(shape=(64, 64)): + img = np.zeros(shape, dtype=np.uint16) + img[12:26, 12:26] = 180 + img[36:52, 38:54] = 240 + return img + + +def _write_tif(path: Path, data: np.ndarray): + path.parent.mkdir(parents=True, exist_ok=True) + imsave(str(path), data, check_contrast=False) + + +def test_discovery_and_mapping(tmp_path): + root = tmp_path / "input" + fov_a = root / "fov_a" + fov_b = root / "fov_b" + fov_empty = root / "fov_empty" + + image = _make_test_image() + _write_tif(fov_a / "sample_phase.tif", image) + _write_tif(fov_a / "sample_mem.tif", image) + _write_tif(fov_a / "sample_dna.tif", image) + + _write_tif(fov_b / "x_phase.tif", image) + _write_tif(fov_b / "x_mem_a.tif", image) + _write_tif(fov_b / "x_mem_b.tif", image) + + fov_empty.mkdir(parents=True, exist_ok=True) + + discovered = discover_fov_directories(root) + assert [d.name for d in discovered] == ["fov_a", "fov_b"] + + mapped = map_fov_files( + fov_a, + base_pattern="*phase*.tif", + membrane_pattern="*mem*.tif", + dna_pattern="*dna*.tif", + ) + assert mapped.base_file.name == "sample_phase.tif" + assert mapped.membrane_file.name == "sample_mem.tif" + assert mapped.dna_file.name == "sample_dna.tif" + + try: + map_fov_files( + fov_b, + base_pattern="*phase*.tif", + membrane_pattern="*mem*.tif", + dna_pattern="*dna*.tif", + ) + except ValueError as exc: + assert "Ambiguous membrane pattern" in str(exc) + else: + raise AssertionError("Expected ambiguous membrane match to fail") + + +def test_run_batch_analysis_outputs(tmp_path): + input_root = tmp_path / "batch_input" + output_root = tmp_path / "batch_output" + + fov_a = input_root / "fov_a" + fov_b = input_root / "fov_b" + fov_bad = input_root / "fov_bad" + + base = _make_test_image() + membrane = _make_test_image() + dna = _make_test_image() + + _write_tif(fov_a / "phase.tif", base) + _write_tif(fov_a / "mem.tif", membrane) + _write_tif(fov_a / "dna.tif", dna) + + _write_tif(fov_b / "phase.tif", base) + _write_tif(fov_b / "mem.tif", membrane) + + _write_tif(fov_bad / "only_mem.tif", membrane) + + summary = run_batch_analysis( + input_root=input_root, + output_root=output_root, + base_pattern="*phase*.tif", + membrane_pattern="*mem*.tif", + dna_pattern="*dna*.tif", + segmentation_algorithm="Isodata", + binary_closing=0, + binary_dilation=0, + binary_fillholes=False, + la_blocksize=151, + la_offset=0.02, + peak_min_distance_from_edge=10, + peak_min_distance=5, + peak_min_height=5, + max_peaks=100000, + unet_model_type="Pretrained", + unet_pretrained="Ph.C. S. pneumo", + unet_model_path="", + stardist_model_type="Pretrained", + stardist_pretrained="StarDist S. aureus", + stardist_model_path="", + pixel_size=1.0, + inner_mask_thickness=4, + septum_algorithm="Isodata", + baseline_margin=30, + find_septum=False, + find_open_septum=False, + classify_cell_cycle=False, + model="S.aureus Membrane Epi", + custom_model_path="", + custom_model_input="Membrane", + custom_model_maxsize=50, + compute_colocalization=False, + generate_per_fov_report=True, + save_segmentation_tifs=True, + save_merged_csv=True, + continue_on_error=True, + ) + + assert summary["total_fovs"] == 3 + assert summary["success_fovs"] == 2 + assert summary["failed_fovs"] == 1 + + assert (output_root / "fov_a" / "mask.tif").exists() + assert (output_root / "fov_a" / "labels.tif").exists() + assert (output_root / "fov_b" / "mask.tif").exists() + assert (output_root / "fov_b" / "labels.tif").exists() + + assert (output_root / "fov_a" / "Report_fov_a_1" / "Analysis.csv").exists() + assert (output_root / "fov_b" / "Report_fov_b_1" / "Analysis.csv").exists() + + assert (output_root / "batch_merged_analysis.csv").exists() + assert (output_root / "batch_errors.csv").exists() diff --git a/src/napari_mAIcrobe/_tests/cell_methods_test.py b/src/napari_mAIcrobe/_tests/cell_methods_test.py new file mode 100644 index 0000000..41f8fa9 --- /dev/null +++ b/src/napari_mAIcrobe/_tests/cell_methods_test.py @@ -0,0 +1,119 @@ +import numpy as np +import pytest + +from napari_mAIcrobe.mAIcrobe.cells import Cell + + +def _cell(): + cell = Cell.__new__(Cell) + cell.box_margin = 1 + cell.box = (0, 0, 4, 4) + cell.cell_mask = np.zeros((5, 5), dtype=float) + cell.cell_mask[1:4, 1:4] = 1 + cell.fluor_mask = np.arange(25, dtype=float).reshape(5, 5) + cell.short_axis = np.array([[0, 2], [4, 2]]) + cell.stats = {"Baseline": 0} + return cell + + +def test_compute_perim_mask_returns_boundary_pixels(): + cell = _cell() + + perim = cell.compute_perim_mask(2) + + assert perim.shape == cell.cell_mask.shape + assert perim.sum() > 0 + assert perim.sum() <= cell.cell_mask.sum() + + +def test_compute_sept_mask_box_currently_calls_with_wrong_signature(): + cell = _cell() + + with pytest.raises(TypeError): + cell.compute_sept_mask(2, "Box") + + +def test_compute_opensept_mask_isodata_currently_calls_wrong_signature(): + cell = _cell() + + with pytest.raises(TypeError): + cell.compute_opensept_mask(2, "Isodata") + + +def test_compute_sept_mask_invalid_algorithm_returns_none(capsys): + cell = _cell() + + assert cell.compute_sept_mask(2, "Missing") is None + assert "valid algorithm" in capsys.readouterr().out + + +def test_compute_sept_box_draws_short_axis_inside_cell_mask(): + cell = _cell() + + sept = cell.compute_sept_box(2) + + assert sept.shape == (5, 5) + assert sept[:, 2].sum() > 0 + assert np.all(sept <= cell.cell_mask) + + +def test_get_outline_points_handles_edges_and_interior(): + cell = _cell() + data = np.ones((3, 3), dtype=int) + + outline = cell.get_outline_points(data) + + assert set(outline) == { + (0, 0), + (0, 1), + (0, 2), + (1, 0), + (1, 2), + (2, 0), + (2, 1), + (2, 2), + } + + +def test_compute_sept_box_fix_clamps_outline_box_to_mask_shape(): + cell = _cell() + outline = [(0, 0), (2, 3), (4, 4)] + + assert cell.compute_sept_box_fix(outline, (5, 5)) == (0, 0, 4, 4) + + +def test_measure_fluor_handles_full_fraction_top_fraction_and_missing_roi(): + cell = _cell() + fluor = np.array([[1, 2], [3, 4]], dtype=float) + roi = np.array([[1, 0], [1, 1]], dtype=float) + + assert cell.measure_fluor(fluor, roi) == pytest.approx(3) + assert cell.measure_fluor(fluor, roi, fraction=0.5) == pytest.approx(4) + assert cell.measure_fluor(fluor, roi, fraction=0.1) == 0 + assert cell.measure_fluor(fluor, None) == 0 + + +def test_compute_fluor_baseline_can_store_nan_without_background(): + cell = _cell() + cell.box = (1, 1, 3, 3) + mask = np.zeros((7, 7), dtype=int) + mask[2:5, 2:5] = 1 + fluor = np.arange(49, dtype=float).reshape(7, 7) + + cell.compute_fluor_baseline(mask, fluor, margin=1) + + assert np.isnan(cell.stats["Baseline"]) + + +def test_set_image_uses_zero_optional_channel_when_missing(): + cell = _cell() + cell.params = {"find_septum": False, "find_openseptum": False} + cell.perim_mask = np.ones((5, 5)) + cell.cyto_mask = np.ones((5, 5)) + cell.sept_mask = None + fluor = np.ones((5, 5)) + + cell.set_image(fluor, optional=None) + + assert cell.image.shape == (5, 35) + assert cell.image[:, 10:15].sum() == 0 diff --git a/src/napari_mAIcrobe/_tests/cellaverager_test.py b/src/napari_mAIcrobe/_tests/cellaverager_test.py new file mode 100644 index 0000000..a030908 --- /dev/null +++ b/src/napari_mAIcrobe/_tests/cellaverager_test.py @@ -0,0 +1,58 @@ +from types import SimpleNamespace + +import numpy as np +import pytest + +from napari_mAIcrobe.mAIcrobe.cellaverager import CellAverager + + +def test_calculate_cell_outline_removes_eroded_interior(): + binary = np.zeros((5, 5), dtype=int) + binary[1:4, 1:4] = 1 + + outline = CellAverager.calculate_cell_outline(binary) + + assert outline.sum() == 8 + assert outline[2, 2] == 0 + + +def test_calculate_major_axis_returns_two_points_for_outline(): + outline = np.zeros((6, 6), dtype=int) + outline[1:5, 2] = 1 + + axis = CellAverager.calculate_major_axis(outline) + + assert len(axis) == 2 + assert len(axis[0]) == 2 + assert axis[0][0] < axis[1][0] + + +@pytest.mark.parametrize( + ("axis", "expected"), + [ + ([[0, 0], [1, 0]], 0.0), + ([[0, 0], [0, 1]], 90.0), + ([[0, 0], [1, 1]], 135.0), + ([[1, 0], [0, 1]], 45.0), + ], +) +def test_calculate_axis_angle_branches(axis, expected): + assert CellAverager.calculate_axis_angle(axis) == pytest.approx(expected) + + +def test_align_adds_rotated_mask_and_average_builds_model(): + fluor = np.ones((8, 8), dtype=float) + cell_mask = np.zeros((5, 5)) + cell_mask[1:4, 1:4] = 1 + cell = SimpleNamespace( + cell_mask=cell_mask, + image_box=lambda image: image[2:7, 2:7], + ) + averager = CellAverager(fluor) + + averager.align(cell) + averager.average() + + assert len(averager.aligned_fluor_masks) == 1 + assert averager.model is not None + assert averager.model.ndim == 2 diff --git a/src/napari_mAIcrobe/_tests/cellcycleclassifier_test.py b/src/napari_mAIcrobe/_tests/cellcycleclassifier_test.py new file mode 100644 index 0000000..f0807bf --- /dev/null +++ b/src/napari_mAIcrobe/_tests/cellcycleclassifier_test.py @@ -0,0 +1,87 @@ +import numpy as np + +from napari_mAIcrobe.mAIcrobe.cellcycleclassifier import CellCycleClassifier + + +class DummyModel: + def __init__(self, prediction): + self.prediction = np.asarray(prediction) + self.seen_shape = None + + def predict(self, array, verbose=0): + self.seen_shape = array.shape + return self.prediction + + +class DummyCell: + box = (1, 1, 3, 4) + cell_mask = np.ones((3, 4)) + + +def _classifier(model_input, prediction): + classifier = CellCycleClassifier.__new__(CellCycleClassifier) + classifier.max_dim = 6 + classifier.model_input = model_input + classifier.custom = False + classifier.model = DummyModel(prediction) + classifier.fluor_fov = np.arange(36, dtype=float).reshape(6, 6) + classifier.optional_fov = np.arange(36, 72, dtype=float).reshape(6, 6) + return classifier + + +def test_preprocess_image_pads_to_centered_target_shape(): + classifier = CellCycleClassifier.__new__(CellCycleClassifier) + classifier.max_dim = 5 + image = np.ones((3, 3)) + + processed = classifier.preprocess_image(image) + + assert processed.shape == (5, 5, 1) + np.testing.assert_array_equal(processed[1:4, 1:4, 0], image) + assert processed[:, 0, 0].sum() == 0 + assert processed[0, :, 0].sum() == 0 + + +def test_preprocess_image_crops_to_centered_target_shape(): + classifier = CellCycleClassifier.__new__(CellCycleClassifier) + classifier.max_dim = 3 + image = np.arange(25, dtype=float).reshape(5, 5) / 24 + + processed = classifier.preprocess_image(image) + + assert processed.shape == (3, 3, 1) + np.testing.assert_array_equal(processed[:, :, 0], image[1:4, 1:4]) + + +def test_classify_cell_membrane_uses_single_channel_prediction(): + classifier = _classifier("Membrane", [[0.1, 0.8, 0.1]]) + + phase = classifier.classify_cell(DummyCell()) + + assert phase == 2 + assert classifier.model.seen_shape == (1, 100, 100, 1) + + +def test_classify_cell_dna_uses_optional_channel_prediction(): + classifier = _classifier("DNA", [[0.2, 0.2, 0.6]]) + + phase = classifier.classify_cell(DummyCell()) + + assert phase == 3 + assert classifier.model.seen_shape == (1, 100, 100, 1) + + +def test_classify_cell_combined_channels_double_width(): + classifier = _classifier("Membrane+DNA", [[0.9, 0.05, 0.05]]) + + phase = classifier.classify_cell(DummyCell()) + + assert phase == 1 + assert classifier.model.seen_shape == (1, 100, 200, 1) + + +def test_classify_cell_custom_binary_output_maps_to_two_phases(): + classifier = _classifier("Membrane", [[0.7]]) + classifier.custom = True + + assert classifier.classify_cell(DummyCell()) == 2 diff --git a/src/napari_mAIcrobe/_tests/cellmanager_helpers_test.py b/src/napari_mAIcrobe/_tests/cellmanager_helpers_test.py new file mode 100644 index 0000000..70bb040 --- /dev/null +++ b/src/napari_mAIcrobe/_tests/cellmanager_helpers_test.py @@ -0,0 +1,132 @@ +from types import SimpleNamespace + +import numpy as np +import pytest + +from napari_mAIcrobe.mAIcrobe.cells import CellManager + + +def _params(**overrides): + params = { + "pixel_size": 1.0, + "inner_mask_thickness": 4, + "septum_algorithm": "Isodata", + "baseline_margin": 30, + "find_septum": False, + "find_openseptum": False, + "classify_cell_cycle": False, + "model": "S.aureus Membrane Epi", + "custom_model_path": "", + "custom_model_input": "Membrane", + "custom_model_maxsize": 50, + "generate_report": False, + "report_path": "", + "cell_averager": False, + "coloc": False, + } + params.update(overrides) + return params + + +def test_model_requires_dna_for_prebuilt_and_custom_models(): + manager = CellManager(np.zeros((2, 2)), np.zeros((2, 2)), None, _params()) + assert manager._model_requires_dna() is False + + manager.params["model"] = "S.aureus DNA Epi" + assert manager._model_requires_dna() is True + + manager.params["model"] = "custom" + manager.params["custom_model_input"] = "Membrane+DNA" + assert manager._model_requires_dna() is True + + +def test_compute_dna_threshold_returns_nan_without_signal(): + labels = np.ones((3, 3), dtype=int) + + assert np.isnan(CellManager._compute_dna_threshold(labels, None)) + assert np.isnan( + CellManager._compute_dna_threshold(labels, np.zeros((3, 3))) + ) + + +def test_frame_data_returns_2d_or_requested_stack_frame(): + labels = np.stack([np.zeros((2, 2)), np.ones((2, 2))]) + fluor = labels + 10 + optional = labels + 20 + manager = CellManager(labels, fluor, optional, _params()) + + frame_labels, frame_fluor, frame_optional = manager._frame_data(1) + + np.testing.assert_array_equal(frame_labels, np.ones((2, 2))) + np.testing.assert_array_equal(frame_fluor, np.full((2, 2), 11)) + np.testing.assert_array_equal(frame_optional, np.full((2, 2), 21)) + + +def test_rows_to_properties_converts_lists_to_arrays(): + properties = CellManager._rows_to_properties( + {"label": [1, 2], "Area": [3, 4]} + ) + + assert all(isinstance(value, np.ndarray) for value in properties.values()) + np.testing.assert_array_equal(properties["label"], np.array([1, 2])) + + +def test_compute_cell_properties_rejects_mismatched_shapes(): + manager = CellManager( + np.zeros((3, 3)), + np.zeros((4, 3)), + None, + _params(), + ) + + with pytest.raises(ValueError, match="same shape"): + manager.compute_cell_properties() + + +def test_compute_cell_properties_rejects_missing_required_dna(): + manager = CellManager( + np.zeros((3, 3)), + np.zeros((3, 3)), + None, + _params( + classify_cell_cycle=True, + model="S.aureus DNA Epi", + ), + ) + + with pytest.raises(ValueError, match="requires DNA image"): + manager.compute_cell_properties() + + +def test_calculate_dna_ratio_uses_cell_box_and_mask(): + cell = SimpleNamespace( + box=(1, 1, 3, 3), + cell_mask=np.array( + [ + [1, 1, 0], + [1, 0, 0], + [1, 1, 1], + ] + ), + ) + dna = np.zeros((5, 5), dtype=float) + dna[1:4, 1:4] = np.array( + [ + [2, 0, 5], + [3, 9, 1], + [0, 4, 6], + ] + ) + + ratio = CellManager.calculate_DNARatio(cell, dna, thresh=2.5) + + assert ratio == pytest.approx(3 / 6) + + +def test_calculate_dna_ratio_returns_nan_without_dna_or_threshold(): + cell = SimpleNamespace(box=(0, 0, 1, 1), cell_mask=np.ones((2, 2))) + + assert np.isnan(CellManager.calculate_DNARatio(cell, None, thresh=1)) + assert np.isnan( + CellManager.calculate_DNARatio(cell, np.ones((2, 2)), thresh=np.nan) + ) diff --git a/src/napari_mAIcrobe/_tests/cellprocessing_test.py b/src/napari_mAIcrobe/_tests/cellprocessing_test.py new file mode 100644 index 0000000..ee21e3c --- /dev/null +++ b/src/napari_mAIcrobe/_tests/cellprocessing_test.py @@ -0,0 +1,58 @@ +import numpy as np + +from napari_mAIcrobe.mAIcrobe.cellprocessing import ( + bound_rectangle, + bounded_point, + bounded_value, + rotation_matrices, + stats_format, +) + + +def test_bounded_value_and_point_clamp_to_limits(): + assert bounded_value(1, 3, 0) == 1 + assert bounded_value(1, 3, 4) == 3 + assert bounded_value(1, 3, 2) == 2 + + assert bounded_point(0, 10, 5, 8, (-1, 9)) == (0, 8) + assert bounded_point(0, 10, 5, 8, (3, 6)) == (3, 6) + + +def test_bound_rectangle_returns_min_max_and_short_width(): + points = np.array([[3, 5], [1, 9], [6, 7]]) + + assert bound_rectangle(points) == (1, 5, 6, 9, 4) + + +def test_rotation_matrices_respect_step_and_identity_first(): + matrices = rotation_matrices(45) + + assert len(matrices) == 4 + np.testing.assert_allclose(matrices[0], np.eye(2)) + np.testing.assert_allclose( + matrices[2], np.array([[0, 1], [-1, 0]]), atol=1e-7 + ) + + +def test_stats_format_toggles_optional_columns(): + base = { + "find_septum": False, + "find_openseptum": False, + "classify_cell_cycle": False, + } + + labels = [label for label, _digits in stats_format(base)] + assert "frame" not in labels + assert "Septum Median" not in labels + assert "Cell Cycle Phase" not in labels + + extended = { + **base, + "include_frame": True, + "find_septum": True, + "classify_cell_cycle": True, + } + labels = [label for label, _digits in stats_format(extended)] + assert labels[0] == "frame" + assert "Septum Median" in labels + assert "Cell Cycle Phase" in labels diff --git a/src/napari_mAIcrobe/_tests/colocmanager_test.py b/src/napari_mAIcrobe/_tests/colocmanager_test.py new file mode 100644 index 0000000..7ef4790 --- /dev/null +++ b/src/napari_mAIcrobe/_tests/colocmanager_test.py @@ -0,0 +1,80 @@ +from types import SimpleNamespace + +import numpy as np + +from napari_mAIcrobe.mAIcrobe.colocmanager import ColocManager + + +def _cell(): + mask = np.ones((3, 3)) + return SimpleNamespace( + label=7, + box=(1, 1, 3, 3), + cell_mask=mask, + perim_mask=mask, + cyto_mask=mask, + sept_mask=mask, + membsept_mask=mask, + ) + + +def test_pearsons_score_calculates_masked_correlation(): + manager = ColocManager() + channel_1 = np.arange(9, dtype=float).reshape(3, 3) + channel_2 = channel_1 * 2 + mask = np.ones((3, 3)) + + score, _pvalue = manager.pearsons_score(channel_1, channel_2, mask) + + assert score > 0.99 + + +def test_computes_cell_pcc_records_whole_cell_regions(): + manager = ColocManager() + fluor = np.arange(25, dtype=float).reshape(5, 5) + 1 + optional = fluor * 3 + + manager.computes_cell_pcc( + fluor, + optional, + _cell(), + {"find_septum": True}, + cell_label="frame0:7", + ) + + report = manager.report["frame0:7"] + assert report["Whole Cell"] > 0.99 + assert report["Membrane"] > 0.99 + assert report["Cytoplasm"] > 0.99 + assert report["Septum"] > 0.99 + assert report["MembSept"] > 0.99 + + +def test_computes_cell_pcc_drops_cells_with_too_few_pixels(): + manager = ColocManager() + cell = _cell() + cell.cell_mask = np.zeros((3, 3)) + + manager.computes_cell_pcc( + np.ones((5, 5)), + np.ones((5, 5)), + cell, + {"find_septum": False}, + ) + + assert manager.report == {} + + +def test_save_report_writes_sorted_semicolon_csv(tmp_path): + manager = ColocManager() + manager.report = { + "2": {"Whole Cell": 0.2, "Membrane": 0.3, "Cytoplasm": 0.4}, + "1": {"Whole Cell": 0.1, "Membrane": 0.2, "Cytoplasm": 0.3}, + } + + manager.save_report(str(tmp_path), sept=False) + + lines = (tmp_path / "_pcc_report.csv").read_text().splitlines() + assert lines[0] == "Cell ID;Whole Cell;Membrane;Cytoplasm;" + assert lines[1].startswith("1;") + assert lines[2].startswith("2;") diff --git a/src/napari_mAIcrobe/_tests/compute_pickles_test.py b/src/napari_mAIcrobe/_tests/compute_pickles_test.py new file mode 100644 index 0000000..fc3d30c --- /dev/null +++ b/src/napari_mAIcrobe/_tests/compute_pickles_test.py @@ -0,0 +1,92 @@ +import pickle +from types import SimpleNamespace + +import numpy as np + +from napari_mAIcrobe._compute_pickles import compute_pickles + + +def _widget(tmp_path, channel_mode="One Channel"): + label_data = np.zeros((20, 20), dtype=int) + label_data[4:10, 4:12] = 1 + label_data[12:18, 12:18] = 2 + channel_1 = np.arange(400, dtype=float).reshape(20, 20) + channel_2 = np.flipud(channel_1) + + widget = compute_pickles.__new__(compute_pickles) + widget.box_margin = 2 + widget._label_combo = SimpleNamespace( + value=SimpleNamespace(data=label_data) + ) + widget._points_combo = SimpleNamespace( + value=SimpleNamespace( + data=np.array([[5, 5], [13, 13], [0, 0], [5, 5]]), + name="3", + ) + ) + widget._channel_radio = SimpleNamespace(value=channel_mode) + widget.channelone_combo = SimpleNamespace( + value=SimpleNamespace(data=channel_1), + visible=None, + ) + widget.channeltwo_combo = SimpleNamespace( + value=SimpleNamespace(data=channel_2), + visible=None, + ) + widget._path2save = SimpleNamespace(value=str(tmp_path)) + return widget + + +def test_on_channel_change_toggles_second_channel_visibility(): + widget = compute_pickles.__new__(compute_pickles) + widget._channel_radio = SimpleNamespace(value="One Channel") + widget.channeltwo_combo = SimpleNamespace(visible=True) + + widget._on_channel_change() + assert widget.channeltwo_combo.visible is False + + widget._channel_radio.value = "Two Channels" + widget._on_channel_change() + assert widget.channeltwo_combo.visible is True + + +def test_on_run_exports_one_channel_pickles(tmp_path): + widget = _widget(tmp_path, channel_mode="One Channel") + + widget._on_run() + + source = pickle.loads((tmp_path / "Class_3_source.p").read_bytes()) + target = pickle.loads((tmp_path / "Class_3_target.p").read_bytes()) + assert len(source) == 2 + assert target == [3, 3] + assert source[0].shape == (100, 100) + + +def test_on_run_exports_two_channel_side_by_side_crops(tmp_path): + widget = _widget(tmp_path, channel_mode="Two Channels") + + widget._on_run() + + source = pickle.loads((tmp_path / "Class_3_source.p").read_bytes()) + target = pickle.loads((tmp_path / "Class_3_target.p").read_bytes()) + assert len(source) == 2 + assert target == [3, 3] + assert source[0].shape == (100, 200) + + +def test_on_run_returns_when_points_layer_name_is_not_positive(tmp_path): + widget = _widget(tmp_path) + widget._points_combo.value.name = "not-a-class" + + widget._on_run() + + assert not (tmp_path / "Class_3_source.p").exists() + + +def test_on_run_returns_when_required_channel_is_missing(tmp_path): + widget = _widget(tmp_path, channel_mode="Two Channels") + widget.channeltwo_combo.value = None + + widget._on_run() + + assert not (tmp_path / "Class_3_source.p").exists() diff --git a/src/napari_mAIcrobe/_tests/computelabel_test.py b/src/napari_mAIcrobe/_tests/computelabel_test.py new file mode 100644 index 0000000..326562a --- /dev/null +++ b/src/napari_mAIcrobe/_tests/computelabel_test.py @@ -0,0 +1,145 @@ +from types import SimpleNamespace + +import numpy as np + +from napari_mAIcrobe import _computelabel + + +class DummyWidget: + def __init__(self, value=None): + self.value = value + self.visible = None + + +class DummyViewer: + def __init__(self): + self.added = [] + self.layers = {} + + def add_labels(self, data, name): + self.added.append((name, data)) + self.layers[name] = SimpleNamespace(data=data, name=name) + + +def _instance(algorithm="Isodata", timelapse=False, autoalign=False): + base_data = np.ones((2, 4, 4)) if timelapse else np.ones((4, 4)) + fluor_data = np.ones_like(base_data) + viewer = DummyViewer() + viewer.layers["fluor1"] = SimpleNamespace( + data=fluor_data.copy(), name="fluor1" + ) + viewer.layers["fluor2"] = SimpleNamespace( + data=fluor_data.copy(), name="fluor2" + ) + + obj = _computelabel.compute_label.__new__(_computelabel.compute_label) + obj._viewer = viewer + obj._baseimg_combo = DummyWidget(SimpleNamespace(data=base_data)) + obj._fluor1_combo = DummyWidget( + SimpleNamespace(data=fluor_data.copy(), name="fluor1") + ) + obj._fluor2_combo = DummyWidget( + SimpleNamespace(data=fluor_data.copy(), name="fluor2") + ) + obj._closinginput = DummyWidget(0) + obj._dilationinput = DummyWidget(0) + obj._fillholesinput = DummyWidget(False) + obj._autoaligninput = DummyWidget(autoalign) + obj._algorithm_combo = DummyWidget(algorithm) + obj._titlemasklabel = DummyWidget() + obj._placeholder = DummyWidget() + obj._blocksizeinput = DummyWidget(151) + obj._offsetinput = DummyWidget(0.02) + obj._unetradio = DummyWidget("Custom") + obj._path2unet = DummyWidget("model.h5") + obj._unetpretrained = DummyWidget("Ph.C. S. pneumo") + obj._stardistradio = DummyWidget("Custom") + obj._path2stardist = DummyWidget("model_dir") + obj._stardistpretrained = DummyWidget("StarDist S. aureus") + obj._titlewatershedlabel = DummyWidget() + obj._peak_min_distance_from_edge = DummyWidget(1) + obj._peak_min_distance = DummyWidget(1) + obj._peak_min_height = DummyWidget(1) + obj._max_peaks = DummyWidget(10) + obj._timelapse = DummyWidget(timelapse) + return obj + + +def test_base_image_visibility_toggles_timelapse_checkbox(): + obj = _instance() + + obj._on_baseimg_changed(None) + assert obj._timelapse.visible is False + + obj._on_baseimg_changed(SimpleNamespace(data=np.zeros((2, 3, 3)))) + assert obj._timelapse.visible is True + + obj._on_baseimg_changed(SimpleNamespace(data=np.zeros((3, 3)))) + assert obj._timelapse.visible is False + + +def test_algorithm_visibility_for_unet_and_local_average(): + obj = _instance() + + obj._on_algorithm_changed("Unet") + assert obj._unetradio.visible is True + assert obj._unetpretrained.visible is False + assert obj._path2unet.visible is True + assert obj._peak_min_distance.visible is False + + obj._on_algorithm_changed("Local Average") + assert obj._blocksizeinput.visible is True + assert obj._peak_min_distance.visible is True + assert obj._unetradio.visible is False + + +def test_pretrained_toggles_only_when_matching_algorithm(): + obj = _instance("Unet") + obj._on_pretrainedunet_changed("Pretrained") + assert obj._unetpretrained.visible is True + assert obj._path2unet.visible is False + + obj._algorithm_combo.value = "StarDist" + obj._on_pretrainedstardist_changed("Custom") + assert obj._stardistpretrained.visible is False + assert obj._path2stardist.visible is True + + +def test_compute_dispatches_classical_and_adds_layers(monkeypatch): + obj = _instance("Isodata") + mask = np.ones((4, 4), dtype=np.uint16) + labels = np.full((4, 4), 2, dtype=np.uint16) + + monkeypatch.setattr( + _computelabel, + "classical_segmentation", + lambda *args: (mask, labels), + ) + + obj.compute() + + assert [name for name, _data in obj._viewer.added] == ["Mask", "Labels"] + np.testing.assert_array_equal(obj._viewer.layers["Labels"].data, labels) + + +def test_compute_dispatches_timelapse_unet_and_autoaligns(monkeypatch): + obj = _instance("Unet", timelapse=True, autoalign=True) + mask = np.ones((2, 4, 4), dtype=np.uint16) + labels = np.full((2, 4, 4), 2, dtype=np.uint16) + + monkeypatch.setattr( + _computelabel, + "batch_unet_segmentation", + lambda *args: (mask, labels), + ) + monkeypatch.setattr( + _computelabel, + "mask_alignment", + lambda mask_frame, fluor_frame: fluor_frame + 5, + ) + + obj.compute() + + assert obj._viewer.layers["fluor1"].data.shape == (2, 4, 4) + assert np.all(obj._viewer.layers["fluor1"].data == 6) + assert np.all(obj._viewer.layers["fluor2"].data == 6) diff --git a/src/napari_mAIcrobe/_tests/conftest.py b/src/napari_mAIcrobe/_tests/conftest.py index 6d71f14..270c298 100644 --- a/src/napari_mAIcrobe/_tests/conftest.py +++ b/src/napari_mAIcrobe/_tests/conftest.py @@ -1,38 +1,30 @@ +from pathlib import Path + import pytest from skimage.io import imread +DOCS_DIR = Path(__file__).resolve().parents[3] / "docs" + @pytest.fixture def phase_example(): - return imread( - "https://github.com/HenriquesLab/mAIcrobe/raw/main/docs/test_phase.tif" - ) + return imread(DOCS_DIR / "test_phase.tif") @pytest.fixture def membrane_example(): - return imread( - "https://github.com/HenriquesLab/mAIcrobe/raw/main/docs/test_membrane.tif" - ) + return imread(DOCS_DIR / "test_membrane.tif") @pytest.fixture def dna_example(): - return imread( - "https://github.com/HenriquesLab/mAIcrobe/raw/main/docs/test_dna.tif" - ) + return imread(DOCS_DIR / "test_dna.tif") @pytest.fixture def all_sample_data(): return ( - imread( - "https://github.com/HenriquesLab/mAIcrobe/raw/main/docs/test_phase.tif" - ), - imread( - "https://github.com/HenriquesLab/mAIcrobe/raw/main/docs/test_membrane.tif" - ), - imread( - "https://github.com/HenriquesLab/mAIcrobe/raw/main/docs/test_dna.tif" - ), + imread(DOCS_DIR / "test_phase.tif"), + imread(DOCS_DIR / "test_membrane.tif"), + imread(DOCS_DIR / "test_dna.tif"), ) diff --git a/src/napari_mAIcrobe/_tests/mask_test.py b/src/napari_mAIcrobe/_tests/mask_test.py new file mode 100644 index 0000000..9c77f7d --- /dev/null +++ b/src/napari_mAIcrobe/_tests/mask_test.py @@ -0,0 +1,57 @@ +import numpy as np +import pytest + +from napari_mAIcrobe.mAIcrobe.mask import mask_alignment, mask_computation + + +def test_mask_computation_local_average_even_blocksize_is_supported(): + image = np.ones((21, 21), dtype=float) + image[7:14, 7:14] = 0 + + mask = mask_computation( + image, + algorithm="Local Average", + blocksize=10, + closing=0, + dilation=0, + fillholes=False, + ) + + assert mask.shape == image.shape + assert mask.dtype.kind in {"b", "i", "u"} + assert mask[10, 10] == 1 + + +def test_mask_computation_can_fill_holes(): + image = np.ones((20, 20), dtype=float) + image[4:16, 4:16] = 0 + image[8:12, 8:12] = 1 + + unfilled = mask_computation(image, closing=0, fillholes=False) + filled = mask_computation(image, closing=0, fillholes=True) + + assert unfilled[10, 10] == 0 + assert filled[10, 10] + + +def test_mask_computation_invalid_algorithm_raises_unboundlocalerror_today(): + with pytest.raises(UnboundLocalError): + mask_computation(np.zeros((4, 4)), algorithm="Missing") + + +def test_mask_alignment_rejects_shape_mismatch(): + with pytest.raises(ValueError, match="same shape"): + mask_alignment(np.zeros((5, 5)), np.zeros((4, 5))) + + +def test_mask_alignment_preserves_shape_and_intensity_range(): + mask = np.zeros((12, 12), dtype=float) + fluor = np.zeros_like(mask) + mask[3:7, 4:8] = 1 + fluor[3:7, 4:8] = 2 + + aligned = mask_alignment(mask, fluor) + + assert aligned.shape == fluor.shape + assert aligned.max() <= 2.0 + assert aligned.min() >= 0.0 diff --git a/src/napari_mAIcrobe/_tests/reports_test.py b/src/napari_mAIcrobe/_tests/reports_test.py new file mode 100644 index 0000000..c22b098 --- /dev/null +++ b/src/napari_mAIcrobe/_tests/reports_test.py @@ -0,0 +1,82 @@ +import numpy as np +import pandas as pd +import pytest + +from napari_mAIcrobe.mAIcrobe.reports import ReportManager + + +def _params(): + return { + "include_frame": False, + "find_septum": False, + "find_openseptum": False, + "classify_cell_cycle": False, + } + + +def _properties(): + return { + "label": np.array([1]), + "Area": np.array([4.0]), + "Perimeter": np.array([8.0]), + "Eccentricity": np.array([0.5]), + "Baseline": np.array([1.0]), + "Cell Median": np.array([2.0]), + "Membrane Median": np.array([3.0]), + "Cytoplasm Median": np.array([4.0]), + "Cell Cycle Phase": np.array([2]), + } + + +def test_report_manager_pads_cells_to_common_shape(): + cells = [np.zeros((2, 3)), np.ones((4, 2))] + + report = ReportManager(_params(), _properties(), cells) + + assert report.max_shape.tolist() == [4, 3] + assert [cell.shape for cell in report.cells] == [(4, 3), (4, 3)] + assert report.cells[0][0, 0] == 1 + + +def test_generate_report_with_no_cells_still_writes_csv_and_html(tmp_path): + report = ReportManager(_params(), _properties(), []) + + report.generate_report(str(tmp_path)) + + report_dir = tmp_path / "Report_1" + assert (report_dir / "html_report_.html").exists() + csv_path = report_dir / "Analysis.csv" + assert csv_path.exists() + assert pd.read_csv(csv_path)["label"].tolist() == [1] + + +def test_generate_report_with_cell_writes_image_and_phase_counts(tmp_path): + params = {**_params(), "classify_cell_cycle": True} + properties = _properties() + cell = np.zeros((3, 14), dtype=float) + + report = ReportManager(params, properties, [cell]) + report.generate_report(str(tmp_path), report_id="sample") + + report_dir = tmp_path / "Report_sample_1" + assert (report_dir / "_images" / "all_cells.png").exists() + html = (report_dir / "html_report_.html").read_text(encoding="utf-16") + assert "Total cells: 1" in html + assert "Phase 2 cells: 1" in html + + +def test_check_filename_increments_numeric_suffix(tmp_path): + (tmp_path / "Report_1").mkdir() + report = ReportManager(_params(), _properties(), []) + + assert report.check_filename(str(tmp_path / "Report_1")).endswith( + "Report_2" + ) + + +def test_check_filename_non_numeric_suffix_currently_fails(tmp_path): + (tmp_path / "Report_sample").mkdir() + report = ReportManager(_params(), _properties(), []) + + with pytest.raises(ValueError): + report.check_filename(str(tmp_path / "Report_sample")) diff --git a/src/napari_mAIcrobe/_tests/sample_data_hooks_test.py b/src/napari_mAIcrobe/_tests/sample_data_hooks_test.py new file mode 100644 index 0000000..2e2b63c --- /dev/null +++ b/src/napari_mAIcrobe/_tests/sample_data_hooks_test.py @@ -0,0 +1,26 @@ +import numpy as np + +from napari_mAIcrobe import _sample_data + + +def test_sample_data_hooks_return_napari_layer_tuples(monkeypatch): + calls = [] + + def fake_imread(url): + calls.append(url) + return np.ones((2, 3)) + + monkeypatch.setattr(_sample_data, "imread", fake_imread) + + phase = _sample_data.phase_example() + membrane = _sample_data.membrane_example() + dna = _sample_data.dna_example() + + assert phase[0][1]["name"] == "Example S.aureus phase contrast" + assert ( + membrane[0][1]["name"] == "Example S.aureus labeled with membrane dye" + ) + assert dna[0][1]["name"] == "Example S.aureus labeled with DNA dye" + assert phase[0][2] == membrane[0][2] == dna[0][2] == "image" + assert len(calls) == 3 + assert all(call.startswith("https://github.com/") for call in calls) diff --git a/src/napari_mAIcrobe/_tests/segmentation_dispatch_test.py b/src/napari_mAIcrobe/_tests/segmentation_dispatch_test.py new file mode 100644 index 0000000..f8e12e2 --- /dev/null +++ b/src/napari_mAIcrobe/_tests/segmentation_dispatch_test.py @@ -0,0 +1,140 @@ +import numpy as np + +from napari_mAIcrobe.mAIcrobe import segmentation + + +def test_unet_segmentation_uses_custom_model_path(monkeypatch): + calls = {} + + def fake_computelabel_unet(**kwargs): + calls.update(kwargs) + return np.ones((3, 3), dtype=np.uint16), np.arange(9).reshape(3, 3) + + monkeypatch.setattr( + segmentation, "computelabel_unet", fake_computelabel_unet + ) + + mask, labels = segmentation.unet_segmentation( + np.zeros((2, 3, 3)), + pretrained="Custom", + pretrained_name="ignored", + path2model="/tmp/model.hdf5", + binary_closing=1, + binary_dilation=2, + binary_fillholes=True, + ) + + assert calls["path2model"] == "/tmp/model.hdf5" + assert calls["base_image"].shape == (3, 3) + assert calls["closing"] == 1 + assert calls["dilation"] == 2 + assert calls["fillholes"] is True + assert mask.shape == labels.shape == (3, 3) + + +def test_batch_unet_segmentation_stacks_frame_results(monkeypatch): + def fake_unet_segmentation(img, *args): + return img.astype(np.uint16), (img + 10).astype(np.uint16) + + monkeypatch.setattr( + segmentation, "unet_segmentation", fake_unet_segmentation + ) + stack = np.stack([np.ones((2, 2)), np.full((2, 2), 2)]) + + masks, labels = segmentation.batch_unet_segmentation( + stack, "Custom", "ignored", "", 0, 0, False + ) + + assert masks.shape == labels.shape == (2, 2, 2) + np.testing.assert_array_equal(masks[1], np.full((2, 2), 2)) + np.testing.assert_array_equal(labels[0], np.full((2, 2), 11)) + + +def test_stardist_segmentation_uses_custom_model_and_normalization( + monkeypatch, +): + seen = {} + + class FakeStarDist2D: + def __init__(self, _config, name, basedir): + seen["name"] = name + seen["basedir"] = basedir + + def predict_instances(self, image): + seen["image"] = image + return np.array([[0, 1], [2, 0]]), None + + monkeypatch.setattr(segmentation, "StarDist2D", FakeStarDist2D) + monkeypatch.setattr( + segmentation, "normalizePercentile", lambda image: image + 1 + ) + + mask, labels = segmentation.stardist_segmentation( + np.zeros((2, 2)), + pretrained="Custom", + pretrained_name="ignored", + path2model="/tmp/model_dir", + ) + + assert seen["name"] == "model_dir" + assert seen["basedir"] == "/tmp" + np.testing.assert_array_equal(seen["image"], np.ones((2, 2))) + np.testing.assert_array_equal(labels, np.array([[0, 1], [2, 0]])) + np.testing.assert_array_equal( + mask, np.array([[0, 1], [1, 0]], dtype=np.uint16) + ) + + +def test_cellpose_segmentation_uses_first_frame_for_3d_input(monkeypatch): + seen = {} + + class FakeCellpose: + def __init__(self, gpu, model_type): + seen["gpu"] = gpu + seen["model_type"] = model_type + + def eval(self, image, diameter=None): + seen["image"] = image + return np.array([[0, 4], [0, 5]]), None, None, None + + monkeypatch.setattr(segmentation.models, "Cellpose", FakeCellpose) + stack = np.stack([np.ones((2, 2)), np.full((2, 2), 2)]) + + mask, labels = segmentation.cellpose_segmentation(stack) + + assert seen["gpu"] is True + assert seen["model_type"] == "cyto3" + np.testing.assert_array_equal(seen["image"], np.ones((2, 2))) + np.testing.assert_array_equal(labels, np.array([[0, 4], [0, 5]])) + np.testing.assert_array_equal( + mask, np.array([[0, 1], [0, 1]], dtype=np.uint16) + ) + + +def test_classical_segmentation_delegates_to_mask_and_segments(monkeypatch): + class FakeSegmentsManager: + def compute_segments(self, pars, mask): + self.pars = pars + self.mask = mask + self.labels = mask + 3 + + monkeypatch.setattr( + segmentation, + "mask_computation", + lambda **kwargs: np.ones((2, 2), dtype=np.uint16), + ) + monkeypatch.setattr(segmentation, "SegmentsManager", FakeSegmentsManager) + + mask, labels = segmentation.classical_segmentation( + np.zeros((2, 2)), + "Isodata", + 151, + 0.02, + 0, + 0, + False, + {"peak_min_distance": 1}, + ) + + np.testing.assert_array_equal(mask, np.ones((2, 2), dtype=np.uint16)) + np.testing.assert_array_equal(labels, np.full((2, 2), 4, dtype=np.uint16)) diff --git a/src/napari_mAIcrobe/_tests/segments_test.py b/src/napari_mAIcrobe/_tests/segments_test.py new file mode 100644 index 0000000..7029179 --- /dev/null +++ b/src/napari_mAIcrobe/_tests/segments_test.py @@ -0,0 +1,67 @@ +import numpy as np + +from napari_mAIcrobe.mAIcrobe.segments import SegmentsManager + + +def _params(**overrides): + params = { + "peak_min_distance_from_edge": 1, + "peak_min_distance": 2, + "peak_min_height": 1, + "max_peaks": 10, + } + params.update(overrides) + return params + + +def test_clear_all_resets_computed_state(): + manager = SegmentsManager() + manager.features = np.ones((2, 2)) + manager.labels = np.ones((2, 2)) + manager.base_w_features = np.ones((2, 2)) + manager.fluor_w_features = np.ones((2, 2)) + + manager.clear_all() + + assert manager.features is None + assert manager.labels is None + assert manager.base_w_features is None + assert manager.fluor_w_features is None + + +def test_compute_distance_peaks_filters_by_margin_and_sorts_low_to_high(): + mask = np.zeros((12, 12), dtype=int) + mask[2:5, 2:5] = 1 + mask[6:11, 6:11] = 1 + + peaks = SegmentsManager.compute_distance_peaks(mask, _params()) + + assert peaks[0] == (3, 3) + assert peaks[-1] == (8, 8) + + +def test_compute_features_normalizes_minimum_margin_in_params(): + manager = SegmentsManager() + params = _params(peak_min_distance_from_edge=0) + mask = np.zeros((9, 9), dtype=int) + mask[2:7, 2:7] = 1 + + manager.compute_features(params, mask) + + assert params["peak_min_distance_from_edge"] == 1 + assert manager.features.shape == mask.shape + assert manager.features.max() > 0 + + +def test_compute_segments_populates_feature_overlay_and_labels(): + manager = SegmentsManager() + mask = np.zeros((15, 15), dtype=int) + mask[2:6, 2:6] = 1 + mask[9:13, 9:13] = 1 + + manager.compute_segments(_params(), mask) + + assert manager.features is not None + assert manager.base_w_features is not None + assert manager.labels is not None + assert set(np.unique(manager.labels)) >= {0, 1, 2} diff --git a/src/napari_mAIcrobe/_tests/unet_test.py b/src/napari_mAIcrobe/_tests/unet_test.py new file mode 100644 index 0000000..025bd90 --- /dev/null +++ b/src/napari_mAIcrobe/_tests/unet_test.py @@ -0,0 +1,128 @@ +import numpy as np +import pytest + +from napari_mAIcrobe.mAIcrobe import unet + + +class FakeLayer: + output_shape = [(None, 4, 4, 1)] + + +class FakeModel: + layers = [FakeLayer()] + + def __init__(self, klass=2): + self.klass = klass + self.calls = [] + + def predict(self, patch, batch_size=1): + self.calls.append(patch.shape) + result = np.zeros((1, 4, 4, 3), dtype=float) + result[:, :, :, self.klass] = 1 + return result + + +def test_normalize_mi_ma_supports_clipping_and_dtype(): + result = unet.normalize_mi_ma( + np.array([-1, 0, 2], dtype=float), + mi=0, + ma=1, + clip=True, + dtype=np.float32, + ) + + assert result.dtype == np.float32 + np.testing.assert_array_equal( + result, np.array([0, 0, 1], dtype=np.float32) + ) + + +def test_normalize_percentile_maps_values_between_percentiles(): + image = np.arange(100, dtype=float) + + result = unet.normalizePercentile(image, pmin=0, pmax=100) + + assert result[0] == pytest.approx(0) + assert result[-1] == pytest.approx(1) + + +def test_predict_as_tiles_pads_small_images_and_crops_back(): + model = FakeModel(klass=2) + + prediction = unet.predict_as_tiles(np.ones((2, 3)), model) + + assert prediction.shape == (2, 3) + assert prediction.dtype == np.uint8 + assert np.all(prediction == 2) + assert model.calls == [(1, 4, 4, 1)] + + +def test_predict_as_tiles_runs_multiple_tiles_for_larger_image(): + model = FakeModel(klass=1) + + prediction = unet.predict_as_tiles(np.ones((6, 7)), model) + + assert prediction.shape == (6, 7) + assert np.all(prediction == 1) + assert len(model.calls) == 4 + + +def test_computelabel_unet_uses_loaded_model_prediction(monkeypatch): + monkeypatch.setattr(unet, "load_model", lambda path: FakeModel(klass=2)) + + mask, labels = unet.computelabel_unet( + "fake.keras", + np.ones((4, 4), dtype=float), + closing=0, + dilation=0, + fillholes=False, + ) + + assert mask.shape == labels.shape == (4, 4) + assert mask.dtype == bool + assert labels.max() >= 1 + + +def test_download_github_file_raw_returns_cached_path(tmp_path): + cached = tmp_path / "SegmentationModels" / "model.h5" + cached.parent.mkdir() + cached.write_bytes(b"already here") + + result = unet.download_github_file_raw( + "SegmentationModels/model.h5", + tmp_path, + ) + + assert result == str(cached) + + +def test_download_github_file_raw_writes_response_content( + monkeypatch, tmp_path +): + calls = {} + + class FakeResponse: + content = b"model bytes" + + def raise_for_status(self): + calls["raised"] = True + + def fake_get(url, timeout): + calls["url"] = url + calls["timeout"] = timeout + return FakeResponse() + + monkeypatch.setattr(unet.requests, "get", fake_get) + + result = unet.download_github_file_raw("model.h5", tmp_path, branch="dev") + + assert result == str(tmp_path / "model.h5") + assert (tmp_path / "model.h5").read_bytes() == b"model bytes" + assert calls == { + "url": ( + "https://raw.githubusercontent.com/HenriquesLab/mAIcrobe/" + "dev/docs/model.h5" + ), + "timeout": 30, + "raised": True, + } diff --git a/src/napari_mAIcrobe/mAIcrobe/cellprocessing.py b/src/napari_mAIcrobe/mAIcrobe/cellprocessing.py index 593f59f..087d4ab 100644 --- a/src/napari_mAIcrobe/mAIcrobe/cellprocessing.py +++ b/src/napari_mAIcrobe/mAIcrobe/cellprocessing.py @@ -114,8 +114,9 @@ def stats_format(params): Parameters ---------- params : dict - Analysis parameters indicating optional computations (e.g., septum, - cell cycle). + Analysis parameters indicating optional computations (e.g., + septum, cell cycle). If `include_frame` is True, prepends a + `frame` column for report display. Returns ------- @@ -123,12 +124,15 @@ def stats_format(params): Pairs of (label, decimals) to include in report. """ result = [] + if params.get("include_frame", False): + result.append(("frame", 0)) + result.append(("Area", 3)) result.append(("Perimeter", 3)) # result.append(('Length', 3)) # result.append(('Width', 3)) result.append(("Eccentricity", 3)) - # result.append(('Irregularity', 3)) TODO + # result.append(('Irregularity', 3)) result.append(("Baseline", 3)) result.append(("Cell Median", 3)) @@ -141,7 +145,7 @@ def stats_format(params): result.append(("Fluor Ratio 75%", 3)) result.append(("Fluor Ratio 25%", 3)) result.append(("Fluor Ratio 10%", 3)) - # result.append(("Memb+Sept Median", 3)) TODO + # result.append(("Memb+Sept Median", 3)) if params["classify_cell_cycle"]: result.append(("Cell Cycle Phase", 1)) diff --git a/src/napari_mAIcrobe/mAIcrobe/cells.py b/src/napari_mAIcrobe/mAIcrobe/cells.py index 16607ae..9cb07a6 100644 --- a/src/napari_mAIcrobe/mAIcrobe/cells.py +++ b/src/napari_mAIcrobe/mAIcrobe/cells.py @@ -38,7 +38,8 @@ class Cell: Analysis parameters dict controlling region computation and other params. optional : numpy.ndarray, optional - Optional fluorescence image (e.g., DNA), by default None. + Optional fluorescence image (e.g., DNA), by default None. If + None, DNA visualization panels are rendered as zeros. Attributes ---------- @@ -176,7 +177,7 @@ def __init__( # NOTE THE SWAP ON X AND Y self.short_axis = np.rint(np.array([[y1, x1], [y2, x2]])).astype(int) - # CHECK IF SHORT AXIS AND LONG AXIS ARE OUTSIDE OF BOX TODO + # TODO: check if short/long axis can fall outside box. self.cell_mask = self.image_box(regionmask) self.fluor_mask = self.image_box(intensity) @@ -1033,13 +1034,17 @@ def set_image(self, fluor, optional): ---------- fluor : numpy.ndarray Fluorescence image. - optional : numpy.ndarray - Optional fluorescence image. + optional : numpy.ndarray or None + Optional fluorescence image. If None, a zero-valued image + is used for DNA panels. """ fluor = img_as_float(fluor) fluor = exposure.rescale_intensity(fluor) + if optional is None: + optional = np.zeros_like(fluor) + optional = img_as_float(optional) optional = exposure.rescale_intensity(optional) @@ -1103,13 +1108,13 @@ class CellManager: Parameters ---------- label_img : ndarray - Labeled image where each cell is represented by a unique - integer. + Labeled image. Supported shapes are 2D `(Y, X)` and timelapse + 2D+t `(T, Y, X)`. fluor : ndarray - Fluorescence image corresponding to the labeled image. - optional : ndarray - Optional image used for additional calculations (e.g., DNA - content). + Primary fluorescence image matching `label_img` shape. + optional : ndarray or None + Optional secondary image (e.g., DNA) matching `label_img` + shape. Can be None. params : dict Dictionary of parameters controlling the behavior of the class. Keys include: @@ -1156,8 +1161,10 @@ class CellManager: params : dict Dictionary of parameters controlling the behavior of the class. properties : dict or None - Dictionary containing computed properties for each cell. Keys - include: + Dictionary containing per-cell properties. In + timelapse mode, cells from all frames are combined and include a `frame` + key. Keys include: + - "frame" - "label" - "Area" - "Perimeter" @@ -1181,7 +1188,7 @@ class CellManager: Methods ------- compute_cell_properties() - Computes various properties for each cell in the labeled image. + Computes various properties for each cell in the labeled image(s). calculate_DNARatio(cell_object, dna_fov, thresh) Static method to calculate the ratio of area that has discernable DNA signal for a given cell. @@ -1197,17 +1204,16 @@ def __init__(self, label_img, fluor, optional, params): """ Initialize the class with the provided images and parameters. - Parameters: - ----------- + Parameters + ---------- label_img : ndarray - The labeled image where each unique integer represents a - different object or cell. + Label image `(Y, X)` or timelapse label stack `(T, Y, X)`. fluor : ndarray - A fluorescence image to be analysed. Fluorescence metrics - and heatmaps will be computed from this image. - optional : ndarray - An optional image that can be used for additional processing - or analysis, mainly PCC calculations, or classification + Primary fluorescence image/stack with shape matching + `label_img`. + optional : ndarray or None + Optional secondary fluorescence image/stack with shape + matching `label_img`. params : dict A dictionary of parameters used for processing or analysis. @@ -1240,72 +1246,171 @@ def __init__(self, label_img, fluor, optional, params): self.all_cells = None - def compute_cell_properties(self): + def _model_requires_dna(self): + """Return True if the configured classifier input needs DNA.""" + + if self.params["model"] == "custom": + return "DNA" in self.params["custom_model_input"] + + return "DNA" in self.params["model"] + + @staticmethod + def _compute_dna_threshold(label_img, optional_img): + """Compute DNA threshold for one frame. + + Returns NaN when no optional image is available or when no + positive optional signal is present. """ - Compute various properties of cells from a label img and - fluorescence data, including morphology and intensity metrics. It - also supports optional functionalities such as cell cycle - classification, cell averaging, and colocalization analysis. - Attributes: - self.properties (dict): A dictionary containing computed cell - properties, including: - - label: Array of cell labels. - - Area: Array of cell areas. - - Perimeter: Array of cell perimeters. - - Eccentricity: Array of cell eccentricities. - - Baseline: Array of baseline fluorescence intensities. - - Cell Median: Array of median fluorescence intensities - for cells. - - Membrane Median: Array of median fluorescence - intensities for membranes. - - Septum Median: Array of median fluorescence - intensities for septa. - - Cytoplasm Median: Array of median fluorescence - intensities for cytoplasm. - - Fluor Ratio: Array of fluorescence ratios. - - Fluor Ratio 75%: Array of 75th percentile fluorescence - ratios. - - Fluor Ratio 25%: Array of 25th percentile fluorescence - ratios. - - Fluor Ratio 10%: Array of 10th percentile fluorescence - ratios. - - Cell Cycle Phase: Array of cell cycle phase - classifications. - - DNA Ratio: Array of DNA ratios. - - Parameters: - None - - Outputs: - - Updates `self.properties` with computed cell properties. - - Optionally updates `self.all_cells` with mosaics of cell - images for report generation. - - Optionally generates a report if - `self.params["generate_report"]` is True. + if optional_img is None: + return np.nan + + optional_img_cells = optional_img * (label_img > 0).astype(int) + nonzero = optional_img_cells[np.nonzero(optional_img_cells)] + if nonzero.size == 0: + return np.nan + + histcounts, binedges = np.histogram(nonzero, bins="auto") + maxintensity = binedges[np.argmax(histcounts) + 1] + + optimg = optional_img.copy() + optimg[optimg >= maxintensity] = maxintensity + opt_nonzero = optimg[np.nonzero(optimg)] + if opt_nonzero.size == 0: + return np.nan + + return threshold_isodata(opt_nonzero) + + def _append_cell_row(self, rows, c, frame_index, dna_img, dnathresh): + """Append one cell row to accumulator lists. + + DNA ratio is stored as NaN when DNA data is unavailable for the + frame. + """ + + rows["frame"].append(frame_index) + rows["label"].append(c.label) + rows["Area"].append(c.stats["Area"]) + rows["Perimeter"].append(c.stats["Perimeter"]) + rows["Eccentricity"].append(c.stats["Eccentricity"]) + rows["Baseline"].append(c.stats["Baseline"]) + rows["Cell Median"].append(c.stats["Cell Median"]) + rows["Membrane Median"].append(c.stats["Membrane Median"]) + rows["Septum Median"].append(c.stats["Septum Median"]) + rows["Cytoplasm Median"].append(c.stats["Cytoplasm Median"]) + rows["Fluor Ratio"].append(c.stats["Fluor Ratio"]) + rows["Fluor Ratio 75%"].append(c.stats["Fluor Ratio 75%"]) + rows["Fluor Ratio 25%"].append(c.stats["Fluor Ratio 25%"]) + rows["Fluor Ratio 10%"].append(c.stats["Fluor Ratio 10%"]) + rows["Cell Cycle Phase"].append(c.stats["Cell Cycle Phase"]) + + if dna_img is None or np.isnan(dnathresh): + rows["DNA Ratio"].append(np.nan) + else: + rows["DNA Ratio"].append( + self.calculate_DNARatio(c, dna_img, dnathresh) + ) + + @staticmethod + def _init_rows_dict(): + """Create property accumulators for output rows.""" + return { + "frame": [], + "label": [], + "Area": [], + "Perimeter": [], + "Eccentricity": [], + "Baseline": [], + "Cell Median": [], + "Membrane Median": [], + "Septum Median": [], + "Cytoplasm Median": [], + "Fluor Ratio": [], + "Fluor Ratio 75%": [], + "Fluor Ratio 25%": [], + "Fluor Ratio 10%": [], + "Cell Cycle Phase": [], + "DNA Ratio": [], + } + + @staticmethod + def _rows_to_properties(rows): + """Convert list in dicts to np arrays.""" + return {key: np.array(values) for key, values in rows.items()} + + def _frame_data(self, frame_index): + """Return one frame as 2D arrays. + + For 2D inputs, returns the original arrays regardless of + `frame_index`. + """ + + if self.label_img.ndim == 2: + return self.label_img, self.fluor_img, self.optional_img + + optional = None + if self.optional_img is not None: + optional = self.optional_img[frame_index] + + return ( + self.label_img[frame_index], + self.fluor_img[frame_index], + optional, + ) + + def compute_cell_properties(self): + """Compute per-cell properties from 2D or 2D+t timelapse data. + + The method validates input shapes, processes each image or each frame + independently for 2D+t inputs, and stores property arrays in + `self.properties`. + + Notes + ----- + - Timelapse mode is enabled for `(T, Y, X)` arrays and adds a + `frame` property column. + - No tracking is performed; labels are treated independently per + frame. + - DNA-dependent metrics (`DNA Ratio`, colocalization) are + skipped or set to NaN when optional input is unavailable. + - Classification raises a ValueError when the selected model + requires DNA but no optional input is provided. """ - Label = [] - Area = [] - Perimeter = [] - Eccentricity = [] - Baseline = [] - CellMedian = [] - Membrane_Median = [] - Septum_Median = [] - Cytoplasm_Median = [] - Fluor_Ratio = [] - Fluor_Ratio_75 = [] - Fluor_Ratio_25 = [] - Fluor_Ratio_10 = [] - CellCyclePhase = [] - DNARatio = [] - All_Cells = [] # TODO consider always saving - - CellsImage = [] - - if self.params["classify_cell_cycle"]: - print("Cell cycle...") + if self.label_img.ndim not in (2, 3): + raise ValueError("label_img must be 2D or 3D (T, Y, X)") + + if self.fluor_img.ndim != self.label_img.ndim: + raise ValueError("label_img and fluor_img must have same dims") + + if self.fluor_img.shape != self.label_img.shape: + raise ValueError("label_img and fluor_img must have same shape") + + if self.optional_img is not None: + if self.optional_img.ndim != self.label_img.ndim: + raise ValueError( + "optional_img and label_img must have same dims" + ) + if self.optional_img.shape != self.label_img.shape: + raise ValueError( + "optional_img and label_img must have same shape" + ) + + if self.params["classify_cell_cycle"] and self.optional_img is None: + if self._model_requires_dna(): + raise ValueError( + "Selected cell cycle model requires DNA image, " + "but DNA image is missing." + ) + + timelapse = self.label_img.ndim == 3 + self.params["include_frame"] = timelapse + + rows = self._init_rows_dict() + all_cells = [] + + ccc = None + if self.params["classify_cell_cycle"] and not timelapse: ccc = CellCycleClassifier( self.fluor_img, self.optional_img, @@ -1314,125 +1419,119 @@ def compute_cell_properties(self): self.params["custom_model_input"], self.params["custom_model_maxsize"], ) - if self.params["cell_averager"]: + + ca = None + if self.params["cell_averager"] and not timelapse: print("Cell averager...") ca = CellAverager(self.fluor_img) - if self.params["coloc"]: + coloc = None + coloc_enabled = self.params["coloc"] and self.optional_img is not None + if self.params["coloc"] and self.optional_img is None: + print("Colocalization skipped: Optional image not provided.") + if coloc_enabled: coloc = ColocManager() - optional_img_cells = self.optional_img * (self.label_img > 0).astype( - int - ) - histcounts, binedges = np.histogram( - optional_img_cells[np.nonzero(optional_img_cells)], bins="auto" - ) - maxintensity = binedges[np.argmax(histcounts) + 1] + n_frames = self.label_img.shape[0] if timelapse else 1 + print("Per cell stats...") - optimg = self.optional_img.copy() - optimg[optimg >= maxintensity] = maxintensity - dnathresh = threshold_isodata(optimg[np.nonzero(optimg)]) - - proptable = pd.DataFrame( - regionprops_table( - self.label_img, - properties=[ - "label", - "bbox", - "centroid", - "orientation", - "axis_minor_length", - "axis_major_length", - "area", - "perimeter", - "eccentricity", - ], - ) - ) + for frame_index in range(n_frames): + label_img, fluor_img, optional_img = self._frame_data(frame_index) + + if self.params["classify_cell_cycle"] and timelapse: + ccc = CellCycleClassifier( + fluor_img, + optional_img, + self.params["model"], + self.params["custom_model_path"], + self.params["custom_model_input"], + self.params["custom_model_maxsize"], + ) - print("Per cell stats...") - label_list = np.unique(self.label_img) - for i, l in enumerate(label_list): - - if l == 0: # BG - continue - - mask = (self.label_img == l).astype(int) - c = Cell( - label=l, - regionmask=mask, - intensity=self.fluor_img, - properties=proptable[proptable["label"] == l], - params=self.params, - optional=self.optional_img, + dnathresh = self._compute_dna_threshold(label_img, optional_img) + + proptable = pd.DataFrame( + regionprops_table( + label_img, + properties=[ + "label", + "bbox", + "centroid", + "orientation", + "axis_minor_length", + "axis_major_length", + "area", + "perimeter", + "eccentricity", + ], + ) ) - if self.params["generate_report"]: - All_Cells.append(c.image) - if self.params["cell_averager"]: - ca.align(c) - - Label.append(c.label) - Area.append(c.stats["Area"]) - Perimeter.append(c.stats["Perimeter"]) - Eccentricity.append(c.stats["Eccentricity"]) - Baseline.append(c.stats["Baseline"]) - CellMedian.append(c.stats["Cell Median"]) - Membrane_Median.append(c.stats["Membrane Median"]) - Septum_Median.append(c.stats["Septum Median"]) - Cytoplasm_Median.append(c.stats["Cytoplasm Median"]) - Fluor_Ratio.append(c.stats["Fluor Ratio"]) - Fluor_Ratio_75.append(c.stats["Fluor Ratio 75%"]) - Fluor_Ratio_25.append(c.stats["Fluor Ratio 25%"]) - Fluor_Ratio_10.append(c.stats["Fluor Ratio 10%"]) - if self.params["classify_cell_cycle"]: - c.stats["Cell Cycle Phase"] = ccc.classify_cell(c) - else: - c.stats["Cell Cycle Phase"] = 0 - CellCyclePhase.append(c.stats["Cell Cycle Phase"]) - DNARatio.append( - self.calculate_DNARatio(c, self.optional_img, dnathresh) - ) - if self.params["coloc"]: - coloc.computes_cell_pcc( - self.fluor_img, self.optional_img, c, self.params + label_list = np.unique(label_img) + for l in label_list: + if l == 0: + continue + + mask = (label_img == l).astype(int) + c = Cell( + label=l, + regionmask=mask, + intensity=fluor_img, + properties=proptable[proptable["label"] == l], + params=self.params, + optional=optional_img, ) - properties = {} - properties["label"] = np.array(Label) - properties["Area"] = np.array(Area) - properties["Perimeter"] = np.array(Perimeter) - properties["Eccentricity"] = np.array(Eccentricity) - properties["Baseline"] = np.array(Baseline) - properties["Cell Median"] = np.array(CellMedian) - properties["Membrane Median"] = np.array(Membrane_Median) - properties["Septum Median"] = np.array(Septum_Median) - properties["Cytoplasm Median"] = np.array(Cytoplasm_Median) - properties["Fluor Ratio"] = np.array(Fluor_Ratio) - properties["Fluor Ratio 75%"] = np.array(Fluor_Ratio_75) - properties["Fluor Ratio 25%"] = np.array(Fluor_Ratio_25) - properties["Fluor Ratio 10%"] = np.array(Fluor_Ratio_10) - properties["Cell Cycle Phase"] = np.array(CellCyclePhase) - properties["DNA Ratio"] = np.array(DNARatio) - - self.properties = properties - - if self.params["cell_averager"]: + if self.params["generate_report"]: + all_cells.append(c.image) + + if self.params["cell_averager"]: + ca.fluor = fluor_img + ca.align(c) + + if self.params["classify_cell_cycle"]: + c.stats["Cell Cycle Phase"] = ccc.classify_cell(c) + else: + c.stats["Cell Cycle Phase"] = 0 + + self._append_cell_row( + rows, + c, + frame_index, + optional_img, + dnathresh, + ) + + if coloc_enabled: + report_key = str(c.label) + if timelapse: + report_key = f"{frame_index}:{c.label}" + coloc.computes_cell_pcc( + fluor_img, + optional_img, + c, + self.params, + cell_label=report_key, + ) + + self.properties = self._rows_to_properties(rows) + + if self.params["cell_averager"] and len(ca.aligned_fluor_masks) > 0: ca.average() self.heatmap_model = ca.model if self.params["generate_report"]: - self.all_cells = All_Cells + self.all_cells = all_cells rm = ReportManager( parameters=self.params, properties=self.properties, - allcells=All_Cells, + allcells=all_cells, ) rm.generate_report( self.params["report_path"], report_id=self.params.get("report_id", None), ) - if self.params["coloc"]: + if coloc_enabled: coloc.save_report( rm.cell_data_filename, self.params["find_septum"] ) @@ -1446,17 +1545,21 @@ def calculate_DNARatio(cell_object, dna_fov, thresh): ---------- cell_object : Cell The cell object for which to calculate the DNA ratio. - dna_fov : np.ndarray - The field of view image containing the DNA signal. + dna_fov : np.ndarray or None + The field-of-view image containing the DNA signal. thresh : float The threshold value for determining discernable DNA signal. Returns ------- float - The ratio of discernable DNA signal area to total cell area. + The ratio of discernable DNA signal area to total cell area, + or NaN when DNA data/threshold is unavailable. """ + if dna_fov is None or np.isnan(thresh): + return np.nan + x0, y0, x1, y1 = cell_object.box cell_mask = cell_object.cell_mask optional_cell = dna_fov[x0 : x1 + 1, y0 : y1 + 1] diff --git a/src/napari_mAIcrobe/mAIcrobe/colocmanager.py b/src/napari_mAIcrobe/mAIcrobe/colocmanager.py index a672213..8ce024f 100644 --- a/src/napari_mAIcrobe/mAIcrobe/colocmanager.py +++ b/src/napari_mAIcrobe/mAIcrobe/colocmanager.py @@ -10,7 +10,8 @@ class ColocManager: Attributes ---------- report : dict - Mapping of cell label (str) to computed metrics + Mapping of identifiers (cell label) (str) to computed metrics. In + timelapse mode, uses frame:cell_label as key to distinguish cells across frames. """ def __init__(self): @@ -80,7 +81,9 @@ def pearsons_score(self, channel_1, channel_2, mask): return pearsonr(filtered_1, filtered_2) - def computes_cell_pcc(self, fluor_image, optional_image, cell, parameters): + def computes_cell_pcc( + self, fluor_image, optional_image, cell, parameters, cell_label=None + ): """Compute and store Pearson metrics for a single cell. Parameters @@ -93,9 +96,12 @@ def computes_cell_pcc(self, fluor_image, optional_image, cell, parameters): Cell object with region masks and bounding box. parameters : dict Analysis parameters including `find_septum`. + cell_label : str or None, optional + Optional identifier used as key in `self.report`. Defaults + to `cell.label`. """ - key = str(cell.label) + key = str(cell.label) if cell_label is None else str(cell_label) self.report[key] = {} x0, y0, x1, y1 = cell.box diff --git a/src/napari_mAIcrobe/mAIcrobe/reports.py b/src/napari_mAIcrobe/mAIcrobe/reports.py index 18769e7..1443a00 100644 --- a/src/napari_mAIcrobe/mAIcrobe/reports.py +++ b/src/napari_mAIcrobe/mAIcrobe/reports.py @@ -19,7 +19,8 @@ class ReportManager: parameters : dict Analysis parameters dictionary. properties : dict - Per-cell properties dictionary (e.g., Label, Area, etc.). + Per-cell properties dictionary (e.g., label, frame, Area, + etc.). allcells : list[numpy.ndarray] List of per-cell montage images for visualization. @@ -48,28 +49,42 @@ def __init__(self, parameters, properties, allcells): Per-cell properties. allcells : list[numpy.ndarray] List of per-cell montage images. + + Notes + ----- + If `allcells` is empty, report metadata is still initialized and + CSV export remains available. """ self.cells = allcells - self.max_shape = np.max([cell.shape for cell in self.cells], axis=0) - - paddiffx = [(self.max_shape[0] - cell.shape[0]) for cell in self.cells] - paddiffy = [(self.max_shape[1] - cell.shape[1]) for cell in self.cells] - - padx = [(p // 2, p - p // 2) for p in paddiffx] - # pady = [(p//2,p-p//2) for p in paddiffy] - - padded_cells = [ - np.pad( - cell, - [(padx[idx][0], padx[idx][1]), (0, paddiffy[idx])], - mode="constant", - constant_values=1, + if len(self.cells) > 0: + self.max_shape = np.max( + [cell.shape for cell in self.cells], axis=0 ) - for idx, cell in enumerate(self.cells) - ] - self.cells = padded_cells + + paddiffx = [ + (self.max_shape[0] - cell.shape[0]) for cell in self.cells + ] + paddiffy = [ + (self.max_shape[1] - cell.shape[1]) for cell in self.cells + ] + + padx = [(p // 2, p - p // 2) for p in paddiffx] + # pady = [(p//2,p-p//2) for p in paddiffy] + + padded_cells = [ + np.pad( + cell, + [(padx[idx][0], padx[idx][1]), (0, paddiffy[idx])], + mode="constant", + constant_values=1, + ) + for idx, cell in enumerate(self.cells) + ] + self.cells = padded_cells + else: + self.max_shape = (1, 1) self.properties = properties self.params = parameters @@ -84,6 +99,11 @@ def html_report(self, filename): ---------- filename : str Output directory path for the HTML report and images. + + Notes + ----- + HTML content is written only when at least one cell montage is + available. """ cells = self.cells """generates an html report with the all the cell stats from the @@ -152,7 +172,7 @@ def html_report(self, filename): selects.append(lin) report.append( - "\n

mAIcrobe Report - https://github.com/HenriquesLab/mAIcrobe/blob/main/docs/user-guide/getting-started.md

" + "\n

mAIcrobe Report - https://github.com/HenriquesLab/mAIcrobe/blob/main/docs/user-guide/getting-started.md

" ) report.append( diff --git a/src/napari_mAIcrobe/mAIcrobe/unet.py b/src/napari_mAIcrobe/mAIcrobe/unet.py index 6642939..ae458eb 100644 --- a/src/napari_mAIcrobe/mAIcrobe/unet.py +++ b/src/napari_mAIcrobe/mAIcrobe/unet.py @@ -231,7 +231,7 @@ def computelabel_unet(path2model, base_image, closing, dilation, fillholes): # edges = prediction==1 insides = prediction == 2 - for _ in range(0): # TODO + for _ in range(0): insides = binary_erosion(insides) insides = insides.astype(np.uint16) insides, _ = lbl(insides) diff --git a/src/napari_mAIcrobe/napari.yaml b/src/napari_mAIcrobe/napari.yaml index 82c8df9..847424c 100644 --- a/src/napari_mAIcrobe/napari.yaml +++ b/src/napari_mAIcrobe/napari.yaml @@ -23,6 +23,9 @@ contributions: - id: napari-mAIcrobe.compute_pickles python_name: napari_mAIcrobe._compute_pickles:compute_pickles title: Save annotated cells as pickles + - id: napari-mAIcrobe.batch_analysis + python_name: napari_mAIcrobe._batchanalysis:batch_analysis + title: Batch analysis sample_data: - command: napari-mAIcrobe.phase_example display_name: Phase contrast S. aureus @@ -42,3 +45,5 @@ contributions: display_name: Filter cells - command: napari-mAIcrobe.compute_pickles display_name: Save annotated cells as pickles + - command: napari-mAIcrobe.batch_analysis + display_name: Batch analysis