Source code for pyfli.simulator.image_generator_common

# pyfli/simulator/image_generator_common.py

"""
Shared per-ROI mask loading, simulator selection, and pixel-loop logic for the
full-image FLI dataset generators.

``combined/sim_image_generator.py`` (:class:`~pyfli.simulator.combined.sim_image_generator.FLIImageGenerator`,
wrapping :class:`~pyfli.simulator.combined.main_factory.MacroSimulator`/
:class:`~pyfli.simulator.combined.main_factory.TCSPCSimulator`) and
``separate/sim_model_image_generator.py`` (:class:`~pyfli.simulator.separate.sim_model_image_generator.FLIModelImageGenerator`,
wrapping :class:`~pyfli.simulator.separate.main_factory_gen.ContinuousSimulator`/
:class:`~pyfli.simulator.separate.main_factory_gen.PhotonCountSimulator`) implement
identical mask-loading, per-ROI simulator dispatch, and pixel-loop logic; they differ
only in which pair of simulator classes they dispatch between, and in whether
parameter maps are recorded for the background ROI (0). Subclasses declare those
differences as class attributes; this module owns the actual logic so it is defined
exactly once.
"""

import itertools
from typing import Any

import numpy as np
from PIL import Image
from tqdm import tqdm

from pyfli import logging

from .sim_helper import irf_picker


[docs] class BaseFLIImageGenerator: """ Shared ``__init__``/``generate_image`` logic for FLIImageGenerator/ FLIModelImageGenerator. Subclasses set the following class attributes: continuous_cls : type Simulator class used for ROIs whose effective ``sensor_type`` is ``"continuous"`` (``MacroSimulator`` / ``ContinuousSimulator``). discrete_cls : type Simulator class used for ROIs whose effective ``sensor_type`` is anything else (``TCSPCSimulator`` / ``PhotonCountSimulator``). include_background_roi_in_maps : bool Whether parameter maps are recorded for pixels in the background ROI (ROI value 0), in addition to any labeled ROI. Ignored (treated as ``True``) whenever no ``roi_mask`` was supplied, since in that case ROI 0 is not "background" — it's the only region there is. """ continuous_cls: type discrete_cls: type include_background_roi_in_maps: bool = True @staticmethod def _load_array(source: str) -> np.ndarray: """Loads an image file (PNG, TIFF, or anything Pillow can open) as an array.""" img = Image.open(source) if img.mode in ("P", "PA"): img = img.convert("RGBA") return np.array(img) @staticmethod def _binarize(arr: np.ndarray) -> np.ndarray: """ Binary foreground/background mask: any pixel with a nonzero value (any nonzero channel for color input, any nonzero value for grayscale input at any bit depth) is foreground; pure zero/black is background. An already-binary source passes through unchanged, since re-binarizing it is a no-op. """ return arr.any(axis=-1) if arr.ndim == 3 else (arr != 0) @staticmethod def _resize_labels_nearest(arr: np.ndarray, shape: tuple[int, int]) -> np.ndarray: """Nearest-neighbor resizes an integer-valued label array to ``shape``.""" label_img = Image.fromarray(arr.astype(np.int32), mode="I") resized = label_img.resize((shape[1], shape[0]), Image.NEAREST) return np.array(resized) def __init__( self, irf_data: np.ndarray, intensity_image: str | np.ndarray | None = None, roi_mask: str | np.ndarray | None = None, roi_params: Any | None = None, image_shape: tuple[int, ...] = (32, 32), method: str = "continuous", verbose: bool = True, bool_mask: str | np.ndarray | None = None, ) -> None: self.method = method.lower() self.irf_data = irf_data self.verbose = verbose if bool_mask is None: self.bool_mask = None elif isinstance(bool_mask, str): self.bool_mask = self._binarize(self._load_array(bool_mask)) else: self.bool_mask = np.asarray(bool_mask, dtype=bool) # Without an explicit roi_mask every pixel is ROI 0 by # construction (see below) — that's "the only region", not # "background to exclude", so the exclusion policy only applies # when the caller actually supplied a multi-region ROI mask. self._record_background_roi = ( self.include_background_roi_in_maps or roi_mask is None ) # Loading the intensity Mask if intensity_image is not None: arr = ( self._load_array(intensity_image) if isinstance(intensity_image, str) else np.asarray(intensity_image) ) self.intensity_mask = self._binarize(arr).astype(float) self.shape = self.intensity_mask.shape else: self.intensity_mask = np.ones(image_shape) self.shape = image_shape # loading the ROI Mask (multi-cluster mask) if roi_mask is not None: if isinstance(roi_mask, str): mask_img = Image.open(roi_mask).convert("L") self.roi_mask = np.array( mask_img.resize((self.shape[1], self.shape[0]), Image.NEAREST) ).astype(int) else: arr = np.asarray(roi_mask).astype(int) if arr.shape != tuple(self.shape): arr = self._resize_labels_nearest(arr, self.shape) self.roi_mask = arr else: self.roi_mask = np.zeros(self.shape, dtype=int) # Initialize ROI Simulators dummy_irf, _ = irf_picker(irf_data) # dummy_irf = irf_data[0, 0, :] if irf_data.ndim == 3 else irf_data self.roi_sims = {} unique_rois = np.unique(self.roi_mask) default_sensor_type = ( "continuous" if self.method == "continuous" else "discrete" ) for idx, roi_val in enumerate(unique_rois): cfg = ( roi_params[idx].copy() if (roi_params and idx < len(roi_params)) else {} ) sensor_type = cfg.pop("sensor_type", default_sensor_type) cfg.pop("method", None) SimClass = ( self.continuous_cls if sensor_type.lower() == "continuous" else self.discrete_cls ) self.roi_sims[roi_val] = SimClass(dummy_irf, sensor_type=sensor_type, **cfg)
[docs] def generate_image(self) -> dict[Any, Any]: """Simulates every pixel and assembles the full FLI dataset. Iterates over all ``(i, j)`` pixels, selects the simulator assigned to that pixel's ROI (swapping in a per-pixel normalized IRF slice when ``irf_data`` is 3-D), runs it, and accumulates the results (scaled by the intensity mask) into pre-allocated decay/fit/IRF cubes and parameter maps. If ``bool_mask`` was provided, it is applied as a final multiplicative mask. Returns: dict: ``{"raw_data": {"decay": <H,W,T>, "irf": <H,W,T>}, "results": {"maps": {<param_name>: <H,W> ...}, "TR_maps": {"fit_map": <H,W,T>, "residual_map": decay_cube - fit_cube}}}``. Raises: ValueError: If ``bool_mask`` was provided but its shape does not match the image shape ``(H, W)``. """ h, w = self.shape total_pixels = h * w # determining time-axis length first_roi = next(iter(self.roi_sims)) sample = self.roi_sims[first_roi]() t_len = sample["raw_data"]["decay"].size # Pre-allocate decay_cube = np.zeros((h, w, t_len), dtype=np.float32) fit_cube = np.zeros((h, w, t_len), dtype=np.float32) irf_cube = np.zeros((h, w, t_len), dtype=np.float32) param_maps: dict[Any, np.ndarray] = {} if self.verbose: logging.info( f"Generating {self.method.upper()} FLI Image [{h}x{w}x{t_len}]..." ) pixel_iterator = itertools.product(range(h), range(w)) # --- tqdm OUTSIDE THE LOOP --- with tqdm( total=total_pixels, desc="Simulating Pixels", unit="px", disable=not self.verbose, leave=False, ) as pbar: for i, j in pixel_iterator: roi_val = self.roi_mask[i, j] sim = self.roi_sims[roi_val] # Pixel-wise IRF handling if self.irf_data.ndim == 3: current_irf = self.irf_data[i, j, :] irf_sum = current_irf.sum() norm_irf = current_irf / irf_sum if irf_sum > 0 else current_irf sim.engine.irf = norm_irf else: norm_irf = sim.engine.irf # Run Simulation pixel_data = sim() m = self.intensity_mask[i, j] decay_cube[i, j, :] = pixel_data["raw_data"]["decay"] * m fit_cube[i, j, :] = pixel_data["results"]["TR_maps"]["fit_map"] * m irf_cube[i, j, :] = norm_irf if self._record_background_roi or roi_val != 0: for k, v in pixel_data["results"]["maps"].items(): if k not in param_maps: param_maps[k] = np.full((h, w), np.nan, dtype=np.float32) param_maps[k][i, j] = v pbar.update(1) if self.bool_mask is not None: if self.bool_mask.shape != (h, w): raise ValueError( f"bool_mask shape {self.bool_mask.shape} does not match image shape {(h, w)}." ) m3 = self.bool_mask[ :, :, np.newaxis ] # (H, W, 1) for broadcasting over time axis decay_cube = decay_cube * m3 fit_cube = fit_cube * m3 irf_cube = irf_cube * m3 for k in param_maps: param_maps[k] = param_maps[k] * self.bool_mask return { "raw_data": {"decay": decay_cube, "irf": irf_cube}, "results": { "maps": param_maps, "TR_maps": {"fit_map": fit_cube, "residual_map": decay_cube - fit_cube}, }, }