From 4b7729b105e3041cda8d85d730a7957fdf5bc3cc Mon Sep 17 00:00:00 2001
From: antmsbrito <50997716+antmsbrito@users.noreply.github.com>
Date: Mon, 8 Jun 2026 11:20:07 +0100
Subject: [PATCH 1/9] Removed todo
---
src/napari_mAIcrobe/mAIcrobe/unet.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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)
From 08fe285a5a45467ca25a311b0408ed9dac58be33 Mon Sep 17 00:00:00 2001
From: antmsbrito <50997716+antmsbrito@users.noreply.github.com>
Date: Mon, 8 Jun 2026 11:22:06 +0100
Subject: [PATCH 2/9] update compute_cells to better handle optional img and
allow for timelapse inputs
---
src/napari_mAIcrobe/_computecells.py | 71 ++-
.../mAIcrobe/cellprocessing.py | 12 +-
src/napari_mAIcrobe/mAIcrobe/cells.py | 469 +++++++++++-------
src/napari_mAIcrobe/mAIcrobe/colocmanager.py | 12 +-
src/napari_mAIcrobe/mAIcrobe/reports.py | 58 ++-
5 files changed, 397 insertions(+), 225 deletions(-)
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/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..3a50818 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`.
"""
- 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 == 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.
+ """
+
+ 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
"
+ "\n"
)
report.append(
From 5de78172f318b4d42d1d38488365e459904a539b Mon Sep 17 00:00:00 2001
From: antmsbrito <50997716+antmsbrito@users.noreply.github.com>
Date: Tue, 9 Jun 2026 16:45:50 +0100
Subject: [PATCH 3/9] add batch analysis and update napari.yaml
---
src/napari_mAIcrobe/_batchanalysis.py | 658 ++++++++++++++++++++++++++
src/napari_mAIcrobe/mAIcrobe/cells.py | 36 +-
src/napari_mAIcrobe/napari.yaml | 5 +
3 files changed, 681 insertions(+), 18 deletions(-)
create mode 100644 src/napari_mAIcrobe/_batchanalysis.py
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/mAIcrobe/cells.py b/src/napari_mAIcrobe/mAIcrobe/cells.py
index 3a50818..9cb07a6 100644
--- a/src/napari_mAIcrobe/mAIcrobe/cells.py
+++ b/src/napari_mAIcrobe/mAIcrobe/cells.py
@@ -1358,24 +1358,24 @@ def _frame_data(self, 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.
- """
+ 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.
+ """
if self.label_img.ndim not in (2, 3):
raise ValueError("label_img must be 2D or 3D (T, Y, X)")
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
From 696cb3470269d7f6b95315521f64e8106dc46906 Mon Sep 17 00:00:00 2001
From: antmsbrito <50997716+antmsbrito@users.noreply.github.com>
Date: Tue, 9 Jun 2026 16:46:13 +0100
Subject: [PATCH 4/9] starting addign tests
---
src/napari_mAIcrobe/_tests/analysis_test.py | 83 ++++++++++
.../_tests/batchanalysis_test.py | 142 ++++++++++++++++++
2 files changed, 225 insertions(+)
create mode 100644 src/napari_mAIcrobe/_tests/batchanalysis_test.py
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_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()
From 08495eb9b79bb909057574178f067cf6d24cdfe5 Mon Sep 17 00:00:00 2001
From: Bruno Manuel Santos Saraiva
Date: Tue, 9 Jun 2026 17:07:43 +0100
Subject: [PATCH 5/9] expanding test suite
---
.../_tests/batchanalysis_helpers_test.py | 174 ++++++++++++++++++
.../_tests/cell_methods_test.py | 119 ++++++++++++
.../_tests/cellaverager_test.py | 58 ++++++
.../_tests/cellcycleclassifier_test.py | 87 +++++++++
.../_tests/cellmanager_helpers_test.py | 128 +++++++++++++
.../_tests/cellprocessing_test.py | 56 ++++++
.../_tests/colocmanager_test.py | 80 ++++++++
.../_tests/compute_pickles_test.py | 90 +++++++++
.../_tests/computelabel_test.py | 137 ++++++++++++++
src/napari_mAIcrobe/_tests/conftest.py | 28 +--
src/napari_mAIcrobe/_tests/mask_test.py | 57 ++++++
src/napari_mAIcrobe/_tests/reports_test.py | 82 +++++++++
.../_tests/sample_data_hooks_test.py | 24 +++
.../_tests/segmentation_dispatch_test.py | 128 +++++++++++++
src/napari_mAIcrobe/_tests/segments_test.py | 67 +++++++
src/napari_mAIcrobe/_tests/unet_test.py | 124 +++++++++++++
16 files changed, 1421 insertions(+), 18 deletions(-)
create mode 100644 src/napari_mAIcrobe/_tests/batchanalysis_helpers_test.py
create mode 100644 src/napari_mAIcrobe/_tests/cell_methods_test.py
create mode 100644 src/napari_mAIcrobe/_tests/cellaverager_test.py
create mode 100644 src/napari_mAIcrobe/_tests/cellcycleclassifier_test.py
create mode 100644 src/napari_mAIcrobe/_tests/cellmanager_helpers_test.py
create mode 100644 src/napari_mAIcrobe/_tests/cellprocessing_test.py
create mode 100644 src/napari_mAIcrobe/_tests/colocmanager_test.py
create mode 100644 src/napari_mAIcrobe/_tests/compute_pickles_test.py
create mode 100644 src/napari_mAIcrobe/_tests/computelabel_test.py
create mode 100644 src/napari_mAIcrobe/_tests/mask_test.py
create mode 100644 src/napari_mAIcrobe/_tests/reports_test.py
create mode 100644 src/napari_mAIcrobe/_tests/sample_data_hooks_test.py
create mode 100644 src/napari_mAIcrobe/_tests/segmentation_dispatch_test.py
create mode 100644 src/napari_mAIcrobe/_tests/segments_test.py
create mode 100644 src/napari_mAIcrobe/_tests/unet_test.py
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/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..e7833d2
--- /dev/null
+++ b/src/napari_mAIcrobe/_tests/cellmanager_helpers_test.py
@@ -0,0 +1,128 @@
+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..5021ac1
--- /dev/null
+++ b/src/napari_mAIcrobe/_tests/cellprocessing_test.py
@@ -0,0 +1,56 @@
+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..1a2a861
--- /dev/null
+++ b/src/napari_mAIcrobe/_tests/compute_pickles_test.py
@@ -0,0 +1,90 @@
+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..c2725fc
--- /dev/null
+++ b/src/napari_mAIcrobe/_tests/computelabel_test.py
@@ -0,0 +1,137 @@
+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..cea9e48 100644
--- a/src/napari_mAIcrobe/_tests/conftest.py
+++ b/src/napari_mAIcrobe/_tests/conftest.py
@@ -1,38 +1,30 @@
import pytest
+from pathlib import Path
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..95cb05f
--- /dev/null
+++ b/src/napari_mAIcrobe/_tests/sample_data_hooks_test.py
@@ -0,0 +1,24 @@
+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..4efd560
--- /dev/null
+++ b/src/napari_mAIcrobe/_tests/segmentation_dispatch_test.py
@@ -0,0 +1,128 @@
+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..4fcd630
--- /dev/null
+++ b/src/napari_mAIcrobe/_tests/unet_test.py
@@ -0,0 +1,124 @@
+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,
+ }
From 728f4edf0e9dfad8b6ce8f8aded498178e6cca96 Mon Sep 17 00:00:00 2001
From: antmsbrito <50997716+antmsbrito@users.noreply.github.com>
Date: Fri, 12 Jun 2026 10:51:19 +0100
Subject: [PATCH 6/9] format tests
---
.../_tests/cellmanager_helpers_test.py | 8 +++++--
.../_tests/cellprocessing_test.py | 4 +++-
.../_tests/compute_pickles_test.py | 4 +++-
.../_tests/computelabel_test.py | 16 +++++++++----
src/napari_mAIcrobe/_tests/conftest.py | 4 ++--
.../_tests/sample_data_hooks_test.py | 4 +++-
.../_tests/segmentation_dispatch_test.py | 24 ++++++++++++++-----
src/napari_mAIcrobe/_tests/unet_test.py | 8 +++++--
8 files changed, 53 insertions(+), 19 deletions(-)
diff --git a/src/napari_mAIcrobe/_tests/cellmanager_helpers_test.py b/src/napari_mAIcrobe/_tests/cellmanager_helpers_test.py
index e7833d2..70bb040 100644
--- a/src/napari_mAIcrobe/_tests/cellmanager_helpers_test.py
+++ b/src/napari_mAIcrobe/_tests/cellmanager_helpers_test.py
@@ -44,7 +44,9 @@ 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))))
+ assert np.isnan(
+ CellManager._compute_dna_threshold(labels, np.zeros((3, 3)))
+ )
def test_frame_data_returns_2d_or_requested_stack_frame():
@@ -61,7 +63,9 @@ def test_frame_data_returns_2d_or_requested_stack_frame():
def test_rows_to_properties_converts_lists_to_arrays():
- properties = CellManager._rows_to_properties({"label": [1, 2], "Area": [3, 4]})
+ 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]))
diff --git a/src/napari_mAIcrobe/_tests/cellprocessing_test.py b/src/napari_mAIcrobe/_tests/cellprocessing_test.py
index 5021ac1..ee21e3c 100644
--- a/src/napari_mAIcrobe/_tests/cellprocessing_test.py
+++ b/src/napari_mAIcrobe/_tests/cellprocessing_test.py
@@ -29,7 +29,9 @@ def test_rotation_matrices_respect_step_and_identity_first():
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)
+ np.testing.assert_allclose(
+ matrices[2], np.array([[0, 1], [-1, 0]]), atol=1e-7
+ )
def test_stats_format_toggles_optional_columns():
diff --git a/src/napari_mAIcrobe/_tests/compute_pickles_test.py b/src/napari_mAIcrobe/_tests/compute_pickles_test.py
index 1a2a861..fc3d30c 100644
--- a/src/napari_mAIcrobe/_tests/compute_pickles_test.py
+++ b/src/napari_mAIcrobe/_tests/compute_pickles_test.py
@@ -15,7 +15,9 @@ def _widget(tmp_path, channel_mode="One Channel"):
widget = compute_pickles.__new__(compute_pickles)
widget.box_margin = 2
- widget._label_combo = SimpleNamespace(value=SimpleNamespace(data=label_data))
+ 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]]),
diff --git a/src/napari_mAIcrobe/_tests/computelabel_test.py b/src/napari_mAIcrobe/_tests/computelabel_test.py
index c2725fc..326562a 100644
--- a/src/napari_mAIcrobe/_tests/computelabel_test.py
+++ b/src/napari_mAIcrobe/_tests/computelabel_test.py
@@ -25,14 +25,22 @@ 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")
+ 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._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)
diff --git a/src/napari_mAIcrobe/_tests/conftest.py b/src/napari_mAIcrobe/_tests/conftest.py
index cea9e48..270c298 100644
--- a/src/napari_mAIcrobe/_tests/conftest.py
+++ b/src/napari_mAIcrobe/_tests/conftest.py
@@ -1,7 +1,7 @@
-import pytest
from pathlib import Path
-from skimage.io import imread
+import pytest
+from skimage.io import imread
DOCS_DIR = Path(__file__).resolve().parents[3] / "docs"
diff --git a/src/napari_mAIcrobe/_tests/sample_data_hooks_test.py b/src/napari_mAIcrobe/_tests/sample_data_hooks_test.py
index 95cb05f..2e2b63c 100644
--- a/src/napari_mAIcrobe/_tests/sample_data_hooks_test.py
+++ b/src/napari_mAIcrobe/_tests/sample_data_hooks_test.py
@@ -17,7 +17,9 @@ def fake_imread(url):
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 (
+ 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
diff --git a/src/napari_mAIcrobe/_tests/segmentation_dispatch_test.py b/src/napari_mAIcrobe/_tests/segmentation_dispatch_test.py
index 4efd560..f8e12e2 100644
--- a/src/napari_mAIcrobe/_tests/segmentation_dispatch_test.py
+++ b/src/napari_mAIcrobe/_tests/segmentation_dispatch_test.py
@@ -10,7 +10,9 @@ 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)
+ monkeypatch.setattr(
+ segmentation, "computelabel_unet", fake_computelabel_unet
+ )
mask, labels = segmentation.unet_segmentation(
np.zeros((2, 3, 3)),
@@ -34,7 +36,9 @@ 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)
+ 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(
@@ -46,7 +50,9 @@ def fake_unet_segmentation(img, *args):
np.testing.assert_array_equal(labels[0], np.full((2, 2), 11))
-def test_stardist_segmentation_uses_custom_model_and_normalization(monkeypatch):
+def test_stardist_segmentation_uses_custom_model_and_normalization(
+ monkeypatch,
+):
seen = {}
class FakeStarDist2D:
@@ -59,7 +65,9 @@ def predict_instances(self, image):
return np.array([[0, 1], [2, 0]]), None
monkeypatch.setattr(segmentation, "StarDist2D", FakeStarDist2D)
- monkeypatch.setattr(segmentation, "normalizePercentile", lambda image: image + 1)
+ monkeypatch.setattr(
+ segmentation, "normalizePercentile", lambda image: image + 1
+ )
mask, labels = segmentation.stardist_segmentation(
np.zeros((2, 2)),
@@ -72,7 +80,9 @@ def predict_instances(self, image):
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))
+ 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):
@@ -96,7 +106,9 @@ def eval(self, image, diameter=None):
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))
+ 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):
diff --git a/src/napari_mAIcrobe/_tests/unet_test.py b/src/napari_mAIcrobe/_tests/unet_test.py
index 4fcd630..025bd90 100644
--- a/src/napari_mAIcrobe/_tests/unet_test.py
+++ b/src/napari_mAIcrobe/_tests/unet_test.py
@@ -32,7 +32,9 @@ def test_normalize_mi_ma_supports_clipping_and_dtype():
)
assert result.dtype == np.float32
- np.testing.assert_array_equal(result, np.array([0, 0, 1], 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():
@@ -94,7 +96,9 @@ def test_download_github_file_raw_returns_cached_path(tmp_path):
assert result == str(cached)
-def test_download_github_file_raw_writes_response_content(monkeypatch, tmp_path):
+def test_download_github_file_raw_writes_response_content(
+ monkeypatch, tmp_path
+):
calls = {}
class FakeResponse:
From fe03b23c094d6d2520f7c166cab1ee2d4dd9d20c Mon Sep 17 00:00:00 2001
From: antmsbrito <50997716+antmsbrito@users.noreply.github.com>
Date: Wed, 17 Jun 2026 16:14:10 +0200
Subject: [PATCH 7/9] Bumping version to 0.0.6
---
setup.cfg | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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
From 4dd2a7fdfbeb3dd07ffeeb36783d3faaf77d9c9f Mon Sep 17 00:00:00 2001
From: Bruno Manuel Santos Saraiva
Date: Wed, 17 Jun 2026 15:22:50 +0100
Subject: [PATCH 8/9] readding qt testing on Linux
---
.github/workflows/test_oncall.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/test_oncall.yml b/.github/workflows/test_oncall.yml
index 10ca1af..8bd4849 100644
--- a/.github/workflows/test_oncall.yml
+++ b/.github/workflows/test_oncall.yml
@@ -31,7 +31,7 @@ jobs:
python-version: '3.10'
# these libraries enable testing on Qt on linux
- # - uses: tlambert03/setup-qt-libs@v1 # TODO CHECK IF NEEDED
+ - uses: tlambert03/setup-qt-libs@v1
# note: if you need dependencies from conda, considering using
# setup-miniconda: https://github.com/conda-incubator/setup-miniconda
From 7f0e5981539447de5e9e200fc252d521a6183f2a Mon Sep 17 00:00:00 2001
From: Bruno Manuel Santos Saraiva
Date: Wed, 17 Jun 2026 15:24:26 +0100
Subject: [PATCH 9/9] changing to be compatible with docker container
---
.github/workflows/test_oncall.yml | 28 ++++++++++++++++++++++++++--
1 file changed, 26 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/test_oncall.yml b/.github/workflows/test_oncall.yml
index 8bd4849..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
+ # 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