diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 0746ed2..04e2d52 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -2,9 +2,9 @@ name: Tests
on:
push:
- branches: [ main, dev_1.4, dev_1.7, dev_v0.2.0, dev_v0.2.1, dev_v0.2.2 ]
+ branches: [ main, dev_1.4, dev_1.7, dev_v0.2.0, dev_v0.2.1, dev_v0.2.2, dev_v0.2.3 ]
pull_request:
- branches: [ main, dev_1.4, dev_1.7, dev_v0.2.0, dev_v0.2.1, dev_v0.2.2 ]
+ branches: [ main, dev_1.4, dev_1.7, dev_v0.2.0, dev_v0.2.1, dev_v0.2.2, dev_v0.2.3 ]
jobs:
tests:
diff --git a/README.md b/README.md
index 8428608..f11d17f 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,8 @@
[](LICENSE)
[](https://pypi.org/project/shinier/)
[](https://pypi.org/project/shinier/)
+[](https://shinier.readthedocs.io/)
+[](https://doi.org/10.1016/j.softx.2026.102884)
[](https://github.com/Charestlab/shinier/actions/workflows/tests.yml)
---
@@ -19,6 +21,10 @@
SHINIER is a modern Python implementation of SHINE (Spectrum, Histogram, and Intensity Normalization and Equalization), originally developed in MATLAB by Willenbockel et al., 2010. It provides precise control over luminance, contrast, histograms, and spectral content across large image sets for well-calibrated visual experiments.
+**Full documentation, API reference, and demos: [shinier.readthedocs.io](https://shinier.readthedocs.io/)**
+
+**Paper: [SHINIER (SoftwareX, 2026)](https://doi.org/10.1016/j.softx.2026.102884)**
+
### Key Features and Improvements
- **Color Processing** — New modes for color image control with modern color-space standards (Rec.601 / Rec.709 / Rec.2020).
@@ -153,5 +159,5 @@ See [LICENSE](LICENSE) for more information.
---
Code developed by Nicolas Dupuis-Roy and Mathias Salvas-Hébert
- Version 0.2.2 - Complete technical documentation
+ Version 0.2.3 - Complete technical documentation
diff --git a/documentation/contributing.md b/documentation/contributing.md
index 4589e35..5c89706 100644
--- a/documentation/contributing.md
+++ b/documentation/contributing.md
@@ -10,6 +10,9 @@
[](../LICENSE)
[]()
[](https://pypi.org/project/shinier/)
+[](https://shinier.readthedocs.io/en/latest/)
+[](https://doi.org/10.1016/j.softx.2026.102884)
+[](https://github.com/Charestlab/shinier/actions/workflows/tests.yml)
---
# Contributing to SHINIER
diff --git a/documentation/demos.md b/documentation/demos.md
index a3fb614..20a6864 100644
--- a/documentation/demos.md
+++ b/documentation/demos.md
@@ -10,6 +10,9 @@
[](../LICENSE)
[]()
[](https://pypi.org/project/shinier/)
+[](https://shinier.readthedocs.io/en/latest/)
+[](https://doi.org/10.1016/j.softx.2026.102884)
+[](https://github.com/Charestlab/shinier/actions/workflows/tests.yml)
---
# Demos / How-to-use
diff --git a/documentation/documentation.md b/documentation/documentation.md
index ba9ab3c..f9a9361 100644
--- a/documentation/documentation.md
+++ b/documentation/documentation.md
@@ -10,6 +10,9 @@
[](../LICENSE)
[]()
[](https://pypi.org/project/shinier/)
+[](https://shinier.readthedocs.io/en/latest/)
+[](https://doi.org/10.1016/j.softx.2026.102884)
+[](https://github.com/Charestlab/shinier/actions/workflows/tests.yml)
---
# Documentation
@@ -295,13 +298,12 @@ mode = 2 # hist_match only

**Available Algorithms:**
-- **Exact specification** (`hist_specification=0`): [Coltuc, Bolon & Chassery (2006)]((https://www.cin.ufpe.br/~if751/projetos/artigos/Exact%20Histogram%20Specification.pdf)) algorithm
+- **Exact specification** (`hist_specification=0`): [Coltuc, Bolon & Chassery (2006)](https://www.cin.ufpe.br/~if751/projetos/artigos/Exact%20Histogram%20Specification.pdf) algorithm
- **Specification with noise** (`hist_specification=1`): Legacy version with noise addition
**SSIM Optimization:**
-- `hist_optim=1`: SSIM-based optimization ([Avanaki, 2009](https://link.springer.com/article/10.1007/s10043-009-0119-z)))
+- `hist_optim=1`: SSIM-based optimization ([Avanaki, 2009](https://link.springer.com/article/10.1007/s10043-009-0119-z))
- `hist_iterations`: Number of iterations (default: 10)
-- `step_size`: Step size (default: 34)
### **Spatial-frequency-based matching (Modes 3–4)**
@@ -665,40 +667,53 @@ def show_processing_overview(processor: ImageProcessor, img_idx: int = 0, show_f
---
## StimulusMasker
-`StimulusMasker` is a utility class for creating elliptical masks and applying
-them to images or image sets. It is useful when stimuli should be shown inside a
-controlled region of interest while the outside area is replaced by a constant
-user-defined background value.
-
-Available mask types are:
-
-- `"hard"`: binary ellipse with a sharp border.
-- `"gaussian"`: hard ellipse with a Gaussian-smoothed border.
-- `"feathered_disk"`: linear edge transition with an explicit width in pixels.
-
-The interactive GUI is often the easiest way to choose the right cutoff and
-offset values because it shows the masked image live while sliders are adjusted.
+Helper to **facilitate** the **generation** and **application** of **elliptical masks**.
+Masks can be applied to a single image or a batch. It can generate binary masks with sharp edges
+(`"hard"`, compatible with the rest of SHINIER) or masks with blurred/feathered
+edges blended into a gray background (`"gaussian"`, `"feathered_disk"`, for
+presenting stimuli in your experiments). There are three ways to get a masker;
+once you have one, generating, applying, and saving work the same way
+regardless of which you used.
```python
+import numpy as np
from shinier import StimulusMasker
+# 1. Construct one directly.
masker = StimulusMasker(
image_size=128,
cutoff_a=0.7,
mask_type="feathered_disk",
edge_width=3,
+ background=128,
+ output_dtype=np.uint8,
)
-mask = masker.mask()
-masked_image = masker.apply(image)
-masked_images = masker.apply_all(stim_arr)
+# 2. Or fit one to an existing mask (array, .npy file, or image file).
+fitted_masker = StimulusMasker.from_mask("mask.npy")
-# Opens a Matplotlib GUI with sliders for cutoff, offset, and mask softness.
-mask_from_gui = masker.interactive_mask(image)
+# 3. Or tune one interactively in a Matplotlib GUI (sliders for cutoff,
+# offset, and mask softness).
+interactive_masker = StimulusMasker.from_interactive_mask(image, cutoff_a=0.7)
```

+Once you have a masker, generate, apply, and save from it the same way:
+
+```python
+mask = masker.generate_mask()
+masked_image = masker.apply_mask(image)
+masked_images = masker.apply_mask(stim_arr)
+masked_by_name = masker.apply_mask({"stimulus_01.png": image}) # preserves the name mapping
+
+masker.save_mask("mask.npy")
+masker.save_mask("mask_preview.png", outside_value=128, inside_value=255)
+
+masker.save_masked_stim(image, "stimulus_01_masked.png", background=128, output_dtype=np.uint8)
+masker.save_masked_stim({"stimulus_01.png": image}, "masked_stimuli", background=128, output_dtype=np.uint8)
+```
+
---
@@ -926,7 +941,7 @@ options = Options(
```
**Scientific Rationale:**
-Composite modes (5-8) apply **two sequential transformations** (e.g., spectrum matching followed by histogram matching). Because each transformation modifies the image in ways that can partially undo the effects of the other, a **single pass rarely yields convergence**. As detailed in the original [SHINE documentation](../_static/shine_toolbox.pdf), **iterative application** of both steps allows the algorithm to progressively minimize residual discrepancies between the desired luminance distribution and spectral amplitude structure.
+Composite modes (5-8) apply **two sequential transformations** (e.g., spectrum matching followed by histogram matching). Because each transformation modifies the image in ways that can partially undo the effects of the other, a **single pass rarely yields convergence**. As detailed in the original SHINE documentation, **iterative application** of both steps allows the algorithm to progressively minimize residual discrepancies between the desired luminance distribution and spectral amplitude structure.
1. **Sequential Processing**: Each cycle compensates for the distortions introduced by the preceding transformation (e.g., histogram adjustment altering spectral power).
2. **Convergence**: Repeated alternation drives both properties toward their joint target values.
@@ -937,20 +952,20 @@ Composite modes (5-8) apply **two sequential transformations** (e.g., spectrum m
## Additional Resources
-The examples in this documentation are intentionally minimized. For more **complete usage examples**, see `demos.ipynb` in the documentation folder:
+The examples in this documentation are intentionally minimized. For more **complete usage examples**, see {doc}`Demos / How-to-use `:
- Coding usage
- Interactive CLI usage
-For a **detailed description** of the available **options**, see the `Options` class in `Options.py`; each parameter lists its purpose, allowed values, and default.
+For a **detailed description** of the available **options**, see {class}`shinier.Options`; each parameter lists its purpose, allowed values, and default.
-For **algorithmic details** and a walkthrough of processing steps, **see** the `ImageProcessor` class in `ImageProcessor.py`.
+For **algorithmic details** and a walkthrough of processing steps, see {class}`shinier.ImageProcessor`.
-For **color management** and **gamut-control strategies**, see the `GamutControl` class in `color/GamutControl.py`. Interactive **visual examples** are available at [shinier-web examples](https://charestlab.github.io/shinier-web/).
+For **color management** and **gamut-control strategies**, see {class}`shinier.color.GamutControl`. Interactive **visual examples** are available at [shinier-web examples](https://charestlab.github.io/shinier-web/).
---
Code developed by Nicolas Dupuis-Roy and Mathias Salvas-Hébert
- Version 0.2.2 - Complete technical documentation
+ Version 0.2.3 - Complete technical documentation
diff --git a/documentation/readthedocs/api.md b/documentation/readthedocs/api.md
index 4f7d3ad..a2142e1 100644
--- a/documentation/readthedocs/api.md
+++ b/documentation/readthedocs/api.md
@@ -88,7 +88,7 @@ documentation remains in the Markdown files under `documentation/`.
```{eval-rst}
.. autoclass:: shinier.utils.StimulusMasker
- :members: mask, apply, apply_all, interactive_mask
+ :members: generate_mask, apply_mask, save_mask, save_masked_stim, from_mask, from_interactive_mask, interactive_mask
:exclude-members: __init__, __new__
```
diff --git a/documentation/readthedocs/conf.py b/documentation/readthedocs/conf.py
index e929630..d5a9232 100644
--- a/documentation/readthedocs/conf.py
+++ b/documentation/readthedocs/conf.py
@@ -16,7 +16,7 @@
try:
from shinier import __version__
except Exception:
- __version__ = "0.2.2"
+ __version__ = "0.2.3"
version = __version__
release = __version__
diff --git a/documentation/readthedocs/project-links.md b/documentation/readthedocs/project-links.md
index 4a61100..d9b5e16 100644
--- a/documentation/readthedocs/project-links.md
+++ b/documentation/readthedocs/project-links.md
@@ -3,5 +3,6 @@
Useful external links for SHINIER:
- Article: [SHINIER (ScienceDirect)](https://www.sciencedirect.com/science/article/pii/S2352711026003754)
+- Documentation:
- PyPI:
- GitHub:
diff --git a/pyproject.toml b/pyproject.toml
index b3b9040..3bf21db 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "shinier"
-version = "0.2.2"
+version = "0.2.3"
description = "Python port of the SHINE toolbox with added options (color management, dithering, EHS), optimized for large image sets."
readme = "README.md"
license = "BSD-3-Clause"
@@ -33,6 +33,7 @@ classifiers = [
[project.urls]
Homepage = "https://github.com/Charestlab/shinier"
+Documentation = "https://shinier.readthedocs.io/"
[project.optional-dependencies]
dev = [
diff --git a/src/shinier/SHINIER.py b/src/shinier/SHINIER.py
index 125649c..fbbc6e2 100644
--- a/src/shinier/SHINIER.py
+++ b/src/shinier/SHINIER.py
@@ -345,7 +345,7 @@ def SHINIER_CLI(images: Optional[np.ndarray] = None, masks: Optional[np.ndarray]
opts.ie_methods = _ie_method_names[_he - 1]
as_gray = prompt("Load images as grayscale?", default="No", kind="bool")
- opts.as_gray = as_gray == 1
+ opts.as_gray = as_gray
linear_luminance = prompt("Are pixel values linearly related to luminance?", default=2, kind='choice', choices=[
f"{Bcolors.CHOICE_VALUE}Yes [legacy mode]{Bcolors.ENDC}\n\t- No color-space conversion.\n\t- Assuming input images are linear to luminance.\n\t- All transformations will be applied independently to each channel which may produce out-of-gamut values",
f"{Bcolors.DEFAULT_TEXT}No [default]{Bcolors.ENDC}:\n\t- Assumes input images are regular sRGB images, i.e. gamma-encoded.\n\t- Images will first be converted into CIE xyY color-space\n\t- All transformations will be applied on the luminance channel (Y) of the CIE xyY color space.\n\t- Images are then reconverted into sRGB using transformed luminance channel (Y) and original chromatic channels (x, y),\n\t- This mode should preserves color gamuts",
@@ -419,19 +419,18 @@ def SHINIER_CLI(images: Optional[np.ndarray] = None, masks: Optional[np.ndarray]
if mode in (2, 5, 6, 7, 8):
ho = prompt("Histogram specification with SSIM optimization (see Avanaki, 2009)?", default='y', kind="bool")
- opts.hist_optim = ho != 2
- if ho == 2:
- opts.hist_iterations = prompt("How many SSIM iterations?", default=5, kind="int", min_v=1, max_v=1_000_000)
- opts.step_size = prompt("What is the SSIM step size?", default=34, kind="int", min_v=1, max_v=1_000_000)
+ opts.hist_optim = ho
opts.hist_specification = None
- if not opts.hist_optim:
+ if opts.hist_optim:
+ opts.hist_iterations = prompt("How many SSIM iterations?", default=5, kind="int", min_v=1, max_v=1_000_000)
+ else:
hs = prompt("Which histogram specification?", default=4, kind="choice", choices=[
"Exact with noise (legacy)",
"Coltuc with moving-average filters",
"Coltuc with gaussian filters",
"Coltuc with gaussian filters and noise if residual isoluminant pixels"
])
- opts.hist_specification = hs - 1
+ opts.hist_specification = hs
image_exts = "/".join(f".{ext}" for ext in ACCEPTED_FORMATS)
thp1 = prompt("How should the target histogram be defined?", default=1, kind="choice", choices=[
@@ -451,8 +450,13 @@ def SHINIER_CLI(images: Optional[np.ndarray] = None, masks: Optional[np.ndarray]
opts.target_hist = th
if mode in (3, 4, 5, 6, 7, 8):
- rsel = prompt("What type of rescaling after sf/spec?", default=2, kind="choice",
- choices=["none", "min/max of all images", "avg min/max"])
+ rsel = prompt("What type of rescaling after sf/spec?", default=3, kind="choice",
+ choices=[
+ "none",
+ "per-image stretch to [0, 255]",
+ "dataset absolute min/max mapped to [0, 255] (no clipping)",
+ "dataset average min/max mapped to [0, 255] (outlier images are clipped)",
+ ])
opts.rescaling = rsel - 1
image_exts = "/".join(f".{ext}" for ext in ACCEPTED_FORMATS)
tsp_sel = prompt("How should the target spectrum be defined?", default=1, kind="choice", choices=[
@@ -508,7 +512,7 @@ def SHINIER_CLI(images: Optional[np.ndarray] = None, masks: Optional[np.ndarray]
opts.verbose = prog_info - 2
# ---- Start SHINIER ----
- dataset = ImageDataset(images=images, masks=masks, options=opts) if (images or masks) else ImageDataset(options=opts)
+ dataset = ImageDataset(images=images, masks=masks, options=opts) if (images is not None or masks is not None) else ImageDataset(options=opts)
results = ImageProcessor(dataset=dataset, verbose=opts.verbose, from_cli=True)
console_log("╔══════════════════════════════════════════════════════╗")
diff --git a/src/shinier/__init__.py b/src/shinier/__init__.py
index 2ac52e6..6686434 100644
--- a/src/shinier/__init__.py
+++ b/src/shinier/__init__.py
@@ -17,7 +17,7 @@
# Metadata
__author__ = "Nicolas Dupuis-Roy and Mathias Salvas-Hebert"
-__version__ = "0.2.2"
+__version__ = "0.2.3"
__email__ = "nicolas.dupuis.roy@umontreal.ca"
# For direct importation
diff --git a/src/shinier/utils.py b/src/shinier/utils.py
index a3b707b..00056ab 100644
--- a/src/shinier/utils.py
+++ b/src/shinier/utils.py
@@ -12,7 +12,7 @@
from numpy.lib.stride_tricks import sliding_window_view
from typing import (
Any, Optional, Tuple, Union, NewType, List, Iterable, ClassVar, Sequence,
- Callable, Literal, Dict, Annotated, TYPE_CHECKING, get_args, get_origin)
+ Callable, Literal, Dict, Annotated, TYPE_CHECKING, get_args, get_origin, Mapping)
from PIL import Image
from itertools import chain
@@ -275,49 +275,49 @@ def rgb2gray(image):
return image
MaskType = Literal["hard", "gaussian", "feathered_disk"]
+EdgeBias = Literal["center", "inward"]
@dataclass
class StimulusMasker:
- """Create and apply ellipse masks to image stimuli.
+ """Create elliptical masks and apply them to image stimuli.
Parameters
----------
image_size : int | tuple[int, int]
- Mask/image size in pixels. If int, creates a square mask. If tuple,
- uses ``(height, width)``.
+ Mask size in pixels. Tuples use ``(height, width)``.
cutoff_a : float
Horizontal ellipse radius in normalized coordinates.
cutoff_b : float | None, optional
- Vertical ellipse radius. If None, uses ``cutoff_a`` (circular mask).
+ Vertical ellipse radius. If None, uses ``cutoff_a``.
offset_a : float, optional
Horizontal ellipse offset in normalized coordinates.
offset_b : float, optional
Vertical ellipse offset in normalized coordinates.
mask_type : MaskType, optional
- Mask edge type: ``"hard"``, ``"gaussian"``, or ``"feathered_disk"``.
+ ``"hard"``, ``"gaussian"``, or ``"feathered_disk"``.
sigma : float, optional
- Gaussian standard deviation in pixels, used when
- ``mask_type == "gaussian"``.
+ Gaussian blur in pixels.
edge_width : float, optional
- Transition width in pixels, used when
- ``mask_type == "feathered_disk"``.
+ Feathered edge width in pixels.
+ edge_bias : EdgeBias, optional
+ Where the ``gaussian``/``feathered_disk`` transition falls relative to
+ ``cutoff_a``/``cutoff_b``. No effect when ``mask_type="hard"``.
+
+ - ``"center"``: half inside the boundary, half outside.
+ - ``"inward"``: fully inside; nothing bleeds past the boundary, so a
+ soft mask never shows more than an equivalent ``"hard"`` one.
background : float, optional
- Background value in ``[0, 1]`` outside the mask.
+ Outside-mask value. ``0..1`` is normalized; values above 1 use
+ ``0..255`` scale.
output_dtype : np.dtype | type, optional
Output dtype for masked images.
-
- Notes
- -----
- Input images can be grayscale ``(H, W)`` or channel-based ``(H, W, C)``.
- Integer images are normalized by their dtype range. Float images with max
- value greater than 1 are assumed to be in ``[0, 255]``.
+ preserve_grayscale : bool, optional
+ Keep grayscale inputs as ``(H, W)`` instead of expanding to RGB.
Examples
--------
>>> masker = StimulusMasker(128, 0.7, mask_type="feathered_disk", edge_width=3)
- >>> mask = masker.mask()
- >>> masked_images = masker.apply_all(stim_arr)
- >>> final_mask = masker.interactive_mask(image)
+ >>> masker.save_masked_stim(image, "masked.png", background=128, output_dtype=np.uint8)
"""
image_size: Union[int, Tuple[int, int]]
@@ -325,24 +325,51 @@ class StimulusMasker:
cutoff_b: Optional[float] = None
offset_a: float = 0.0
offset_b: float = 0.0
- mask_type: MaskType = "feathered_disk"
+ mask_type: MaskType = "hard"
sigma: float = 2.0
edge_width: float = 2.0
+ edge_bias: EdgeBias = "center"
background: float = 0.5
output_dtype: Union[np.dtype, type] = np.float64
+ preserve_grayscale: bool = False
- def mask(self) -> np.ndarray:
- """Generate mask as float64 in [0, 1]."""
- cutoff_b = self.cutoff_a if self.cutoff_b is None else self.cutoff_b
- height, width = (self.image_size, self.image_size) if isinstance(self.image_size, int) else self.image_size
+ _IRRELEVANT_PARAMS: ClassVar[Dict[MaskType, Tuple[str, ...]]] = {
+ "hard": ("edge_bias", "sigma", "edge_width"),
+ "gaussian": ("edge_width",),
+ "feathered_disk": ("sigma",),
+ }
+
+ def __post_init__(self) -> None:
+ """Validate mask parameters early so configuration errors are explicit."""
+ height, width = self._mask_shape()
+ self._require_positive("image_size height", height)
+ self._require_positive("image_size width", width)
+ self._require_positive("cutoff_a", self.cutoff_a)
+ if self.cutoff_b is not None:
+ self._require_positive("cutoff_b", self.cutoff_b)
+ self._require_nonnegative("sigma", self.sigma)
+ self._require_nonnegative("edge_width", self.edge_width)
+ self._require_choice("mask_type", self.mask_type, get_args(MaskType))
+ self._require_choice("edge_bias", self.edge_bias, get_args(EdgeBias))
+
+ @staticmethod
+ def _ellipse_geometry(
+ height: int, width: int, cutoff_a: float, cutoff_b: float, offset_a: float, offset_b: float
+ ) -> Tuple[np.ndarray, np.ndarray]:
+ """Normalized ellipse radius (``r < 1`` inside) and signed distance to its boundary, in pixels."""
x = np.linspace(0, 1, width, dtype=np.float64)
y = np.linspace(0, 1, height, dtype=np.float64)
xv, yv = np.meshgrid(x, y)
# Normalized ellipse radius: r < 1 is inside, r = 1 is the boundary.
- r = np.sqrt(
- ((2 * xv - 1 - self.offset_a) / self.cutoff_a) ** 2
- + ((2 * yv - 1 - self.offset_b) / cutoff_b) ** 2
- )
+ r = np.sqrt(((2 * xv - 1 - offset_a) / cutoff_a) ** 2 + ((2 * yv - 1 - offset_b) / cutoff_b) ** 2)
+ radius_pixels = min(cutoff_a * (width - 1), cutoff_b * (height - 1)) / 2
+ return r, (1 - r) * radius_pixels
+
+ def generate_mask(self) -> np.ndarray:
+ """Generate mask as float64 in [0, 1]."""
+ cutoff_b = self.cutoff_a if self.cutoff_b is None else self.cutoff_b
+ height, width = self._mask_shape()
+ r, signed_distance = self._ellipse_geometry(height, width, self.cutoff_a, cutoff_b, self.offset_a, self.offset_b)
m = (r < 1).astype(np.float64)
if (
self.mask_type == "hard"
@@ -351,60 +378,292 @@ def mask(self) -> np.ndarray:
):
return m
if self.mask_type == "gaussian":
- # Blur, then normalize so the maximum mask value is 1.
- m = self._blur(m)
- return np.clip(m / m.max(), 0, 1) if m.max() > 0 else m
+ return self._gaussian_mask(m, height, width, cutoff_b)
if self.mask_type == "feathered_disk":
- # Linear ramp from 0 to 1 across edge_width pixels, centered on the ellipse boundary.
- radius_pixels = min(self.cutoff_a * (width - 1), cutoff_b * (height - 1)) / 2
- signed_distance = (1 - r) * radius_pixels
- return np.clip(signed_distance / self.edge_width + 0.5, 0, 1)
+ # Ramp across edge_width pixels: centered on the boundary, or pulled fully inside it.
+ center = 0.5 if self.edge_bias == "center" else 0.0
+ return np.clip(signed_distance / self.edge_width + center, 0, 1)
raise ValueError(f"Unknown mask_type: {self.mask_type!r}")
- def apply(self, image: np.ndarray) -> np.ndarray:
+ def _gaussian_mask(self, plateau: np.ndarray, height: int, width: int, cutoff_b: float) -> np.ndarray:
+ """Blur the binary plateau, then normalize so the maximum mask value is 1."""
+ if self.edge_bias == "center":
+ blurred = self._blur(plateau)
+ return np.clip(blurred / blurred.max(), 0, 1) if blurred.max() > 0 else blurred
+ # "inward": blur a plateau shrunk by several sigma, then zero out everything past the
+ # original boundary. Outside is exactly 0 (never brighter than a "hard" mask); inside
+ # ramps smoothly from 0 near the boundary up to 1 well within the shrunk shape.
+ margin = 4 * self.sigma
+ cutoff_a = max(self.cutoff_a - 2 * margin / max(width - 1, 1), 1e-3)
+ cutoff_b = max(cutoff_b - 2 * margin / max(height - 1, 1), 1e-3)
+ r_shrunk, _ = self._ellipse_geometry(height, width, cutoff_a, cutoff_b, self.offset_a, self.offset_b)
+ blurred = self._blur((r_shrunk < 1).astype(np.float64))
+ soft = np.clip(blurred / blurred.max(), 0, 1) if blurred.max() > 0 else blurred
+ return np.minimum(plateau, soft)
+
+ @classmethod
+ def from_mask(
+ cls,
+ mask: Union[np.ndarray, str, Path],
+ mask_type: Union[MaskType, Literal["auto"]] = "auto",
+ threshold: float = 0.5,
+ return_error: bool = False,
+ **kwargs: Any,
+ ) -> Union["StimulusMasker", tuple["StimulusMasker", float]]:
+ """Estimate ``StimulusMasker`` parameters from an existing mask.
+
+ Parameters
+ ----------
+ mask : np.ndarray | str | Path
+ Mask array, ``.npy`` path, or image path. Values are normalized from
+ their observed min/max before fitting.
+ mask_type : {"auto", "hard", "gaussian", "feathered_disk"}, optional
+ Type to fit. ``"auto"`` uses ``"hard"`` for binary masks and
+ otherwise picks the better of ``"feathered_disk"`` and ``"gaussian"``.
+ threshold : float, optional
+ Normalized threshold used to estimate the ellipse contour.
+ return_error : bool, optional
+ If True, return ``(masker, mean_squared_error)``.
+ **kwargs : Any
+ Extra constructor arguments for the returned masker.
+
+ Returns
+ -------
+ StimulusMasker | tuple[StimulusMasker, float]
+ Fitted masker, optionally with the mean squared reconstruction error.
+
+ Notes
+ -----
+ Assumes an upright ellipse.
+
+ Examples
+ --------
+ >>> fitted = StimulusMasker.from_mask("mask.npy")
+ >>> fitted.save_masked_stim(image, "image_with_fitted_mask.png", background=128, output_dtype=np.uint8)
"""
- Apply the mask to one image.
+ observed = cls._load_mask_for_fit(mask)
+ cls._validate_fit_request(observed, mask_type, threshold)
+ foreground = cls._threshold_mask(observed, threshold)
+ params = cls._estimate_ellipse_params(foreground)
+ candidates = cls._fit_candidates(observed, mask_type)
+ fits = [cls._fit_mask_candidate(observed, params, candidate, kwargs) for candidate in candidates]
+ best = min(fits, key=lambda item: item[1])
+ cls._log(
+ "StimulusMasker fitted from mask "
+ f"(type={best[0].mask_type}, error={best[1]:.6g})."
+ )
+ return best if return_error else best[0]
+
+ @classmethod
+ def from_interactive_mask(
+ cls,
+ image: np.ndarray,
+ cutoff_a: float = 0.7,
+ **kwargs: Any,
+ ) -> "StimulusMasker":
+ """Create a masker by tuning it in the interactive GUI.
Parameters
----------
image : np.ndarray
- Input image. Accepts ``(H, W)`` grayscale or ``(H, W, C)`` channel
- images. Integer images are normalized by dtype range; float images
- above 1 are assumed to be in ``[0, 255]``.
+ Preview image. The mask size is inferred from this image.
+ cutoff_a : float, optional
+ Initial horizontal ellipse radius.
+ **kwargs : Any
+ Extra constructor arguments.
Returns
-------
- np.ndarray
- Masked image cast to ``output_dtype``.
+ StimulusMasker
+ Masker updated with the GUI-selected parameters.
+
+ Examples
+ --------
+ >>> masker = StimulusMasker.from_interactive_mask(image, cutoff_a=0.7)
+ >>> masker.save_mask("mask.npy")
+ >>> masker.save_masked_stim(image, "masked.png", background=128, output_dtype=np.uint8)
"""
- return self._apply_with_mask(image, self.mask())
+ if "image_size" in kwargs:
+ raise ValueError("image_size is inferred from image; do not pass it to from_interactive_mask.")
+ masker = cls(image_size=np.asarray(image).shape[:2], cutoff_a=cutoff_a, **kwargs)
+ masker.interactive_mask(image)
+ return masker
- def apply_all(self, stimuli: Iterable[np.ndarray]) -> list[np.ndarray]:
- """Apply the same mask to multiple images."""
- m = self.mask()
- return [self._apply_with_mask(stim, m) for stim in stimuli]
-
- def interactive_mask(self, image: np.ndarray) -> np.ndarray:
+ def apply_mask(
+ self,
+ stim: Union[np.ndarray, Mapping[str, np.ndarray], Iterable[np.ndarray]],
+ background: Optional[float] = None,
+ output_dtype: Optional[Union[np.dtype, type]] = None,
+ verbose: bool = True,
+ ) -> Union[np.ndarray, dict[str, np.ndarray], list[np.ndarray]]:
+ """Apply the mask to one image, a batch, or a name-to-image mapping.
+
+ Parameters
+ ----------
+ stim : np.ndarray | Mapping[str, np.ndarray] | Iterable[np.ndarray]
+ Single ``(H, W)``/``(H, W, C)`` image, filename-to-image mapping, or
+ iterable/stack of images.
+ background : float, optional
+ Temporary outside-mask value.
+ output_dtype : np.dtype | type, optional
+ Temporary output dtype.
+ verbose : bool, optional
+ Print the image count and full mask specification (``mask_type``,
+ ``edge_bias``, cutoffs, offsets, ``sigma``, ``edge_width``).
+
+ Returns
+ -------
+ np.ndarray | dict[str, np.ndarray] | list[np.ndarray]
+ Masked image; or masked images keyed by their original name, if
+ ``stim`` was a mapping; or a list of masked images otherwise.
+
+ Notes
+ -----
+ Well suited for masking your whole stimulus set once, up front, before
+ running an experiment: pass a ``{name: image}`` dict and get back a
+ ``{name: masked_image}`` dict, so each stimulus stays identifiable by
+ the same name/id you already use to reference it in your experiment.
+
+ Examples
+ --------
+ >>> masked = masker.apply_mask({"1": img1, "2": img2})
+ >>> masked["1"].shape
+ (128, 128, 3)
+ """
+ m = self.generate_mask()
+ if self._is_single_image(stim):
+ result = self._apply_with_mask(stim, m, background=background, output_dtype=output_dtype)
+ count = 1
+ elif isinstance(stim, Mapping):
+ result = {
+ name: self._apply_one_labeled(name, image, m, background, output_dtype)
+ for name, image in stim.items()
+ }
+ count = len(result)
+ else:
+ result = [
+ self._apply_one_labeled(idx, one, m, background, output_dtype)
+ for idx, one in enumerate(self._iter_images(stim))
+ ]
+ count = len(result)
+ if verbose:
+ spec = ", ".join(f"{name}={self._format_param(value)}" for name, value in self._relevant_params().items())
+ self._log(f"StimulusMasker applied to {count} image(s) -- {spec}")
+ return result
+
+ def save_mask(
+ self,
+ path: Union[str, Path],
+ dtype: Union[np.dtype, type] = np.float32,
+ inside_value: float = 1.0,
+ outside_value: float = 0.0,
+ ) -> Path:
+ """Save the mask as ``.npy`` data or an image preview.
+
+ Parameters
+ ----------
+ path : str | Path
+ Destination path. No suffix defaults to ``.npy``.
+ dtype : np.dtype | type, optional
+ Dtype for ``.npy`` output.
+ inside_value : float, optional
+ Value for fully inside-mask pixels.
+ outside_value : float, optional
+ Value for fully outside-mask pixels.
+
+ Returns
+ -------
+ Path
+ Written path.
+
+ Notes
+ -----
+ Emits a ``RuntimeWarning`` if the mask contains values strictly between
+ ``outside_value`` and ``inside_value`` (blurred/feathered edges) — the
+ saved file is meant for visualization only, not for reuse as a mask.
"""
- Open a Matplotlib GUI for tuning the mask on top of an image.
+ path = Path(path).expanduser()
+ if path.suffix == "":
+ path = path.with_suffix(".npy")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ mask = self.generate_mask() * (inside_value - outside_value) + outside_value
+ lo, hi = sorted((inside_value, outside_value))
+ if np.any((mask > lo) & (mask < hi)):
+ warnings.warn(
+ f"StimulusMasker.save_mask: mask_type={self.mask_type!r} produces intermediate "
+ f"values between outside_value={outside_value} and inside_value={inside_value} "
+ "(blurred/feathered edges). This saved mask is for visualization purposes only "
+ "-- do not reuse it to mask a stimulus later.",
+ RuntimeWarning,
+ stacklevel=2,
+ )
+ if path.suffix.lower() == ".npy":
+ np.save(path, self._cast_mask(mask, dtype))
+ else:
+ self._save_image(self._mask_preview(mask), path)
+ self._log(f"StimulusMasker mask saved: {path}")
+ return path
- The GUI updates the current object in place. Closing the window keeps the
- selected cutoff, offset, mask type, sigma, and edge_width values on self.
+ def save_masked_stim(
+ self,
+ stim: Union[np.ndarray, Mapping[str, np.ndarray], Iterable[np.ndarray]],
+ path: Union[str, Path],
+ names: Optional[Iterable[str]] = None,
+ background: Optional[float] = None,
+ output_dtype: Optional[Union[np.dtype, type]] = None,
+ ) -> Union[Path, list[Path]]:
+ """Apply the mask and save one image or a batch.
+
+ Parameters
+ ----------
+ stim : np.ndarray | Mapping[str, np.ndarray] | Iterable[np.ndarray]
+ Single image, filename-to-image mapping, or iterable of images.
+ path : str | Path
+ File path for one image, or output directory for a batch.
+ names : Iterable[str], optional
+ Filenames for iterable batches.
+ background : float, optional
+ Temporary outside-mask value.
+ output_dtype : np.dtype | type, optional
+ Temporary output dtype.
+
+ Returns
+ -------
+ Path | list[Path]
+ Written path, or written paths for a batch.
+ """
+ if self._is_single_image(stim):
+ return self._save_one(stim, path, background, output_dtype)
+
+ output_dir = Path(path).expanduser()
+ output_dir.mkdir(parents=True, exist_ok=True)
+ items = self._named_images(stim, names)
+ m = self.generate_mask()
+
+ paths: list[Path] = []
+ for name, image in items:
+ output_path = output_dir / self._image_filename(name)
+ masked = self._apply_one_labeled(name, image, m, background, output_dtype)
+ self._save_image(masked, output_path)
+ paths.append(output_path)
+ self._log(f"StimulusMasker saved {len(paths)} masked image(s): {output_dir}")
+ return paths
+
+ def interactive_mask(self, image: np.ndarray) -> np.ndarray:
+ """Tune the current masker with a Matplotlib GUI.
Parameters
----------
image : np.ndarray
- Image used for the masked preview. Accepts ``(H, W)`` grayscale or
- ``(H, W, C)`` channel images. The mask size is automatically set
- from this image.
+ Preview image. The mask size is inferred from this image.
Returns
-------
np.ndarray
- Final mask as ``float64`` in ``[0, 1]``.
+ Final mask in ``[0, 1]``.
"""
- from matplotlib.widgets import Button, Slider
- plt.rcParams["font.family"] = "Times New Roman"
+ from matplotlib.widgets import Button, Slider, TextBox
+ plt.rcParams["font.family"] = "DejaVu Sans"
preview = self._normalize(image)
# RGB/grayscale in 3 channels
if preview.ndim == 2:
@@ -412,100 +671,164 @@ def interactive_mask(self, image: np.ndarray) -> np.ndarray:
preview = preview[:, :, :3]
self.image_size = preview.shape[:2]
+ initial = self._masker_params()
# ---- Layout parameters for aesthetic GUI design ----
height, width = preview.shape[:2]
- fig_width = 6.4
- panel_left = 0.95
- panel_width = 4.90
+ fig_width = 7.2
+ panel_left = 0.80
+ panel_width = 5.70
bottom_margin = 0.35
slider_height = 0.16
slider_gap = 0.10
+ textbox_width = 0.82
+ textbox_gap = 0.18
image_gap = 0.25
softness_gap = 0.28
softness_height = 0.16
button_gap = 0.12
- button_height = 0.24
+ button_height = 0.28
+ reset_gap = 0.14
+ reset_height = 0.26
+ reset_width = 0.80
+ edge_width_btn = 1.55
+ top_button_gap = 0.12
top_margin = 0.25
+ slider_width = panel_width - textbox_width - textbox_gap
slider_block_height = 4 * slider_height + 3 * slider_gap
- image_height = min(panel_width * height / width, 4.7)
+ image_height = min(panel_width * height / width, 4.8)
slider_bottom = bottom_margin
image_bottom = slider_bottom + slider_block_height + image_gap
softness_bottom = image_bottom + image_height + softness_gap
button_bottom = softness_bottom + softness_height + button_gap
- fig_height = button_bottom + button_height + top_margin
+ reset_bottom = button_bottom + button_height + reset_gap
+ fig_height = reset_bottom + reset_height + top_margin
fig = plt.figure(figsize=(fig_width, fig_height))
fig.canvas.manager.set_window_title("Interactive Masking GUI - SHINIER")
- ax_img = fig.add_axes((
- panel_left / fig_width,
- image_bottom / fig_height,
- panel_width / fig_width,
- image_height / fig_height,
- ))
+ axes = lambda left, bottom, w, h: fig.add_axes(
+ (left / fig_width, bottom / fig_height, w / fig_width, h / fig_height)
+ )
+ ax_img = axes(panel_left, image_bottom, panel_width, image_height)
# ----
- shown = ax_img.imshow(self._apply_with_mask(preview, self.mask()))
+ shown = ax_img.imshow(self._apply_with_mask(preview, self.generate_mask()))
ax_img.set_axis_off()
-
+
+ def style_button(button, fontsize=9):
+ button.label.set_color("0.5")
+ button.label.set_fontsize(fontsize)
+ for spine in button.ax.spines.values():
+ spine.set_edgecolor("0.5")
+ spine.set_linewidth(1.0)
+
+ def set_textbox_value(textbox, value, force=False):
+ """Mirror slider values into text boxes without firing callbacks."""
+ if not force and getattr(textbox, "capturekeystrokes", False):
+ return
+ textbox.eventson = False
+ textbox.set_val(f"{value:.3f}")
+ textbox.eventson = True
+
+ def make_on_submit(slider, textbox, vmin, vmax):
+ """Push a typed number to the slider, clamped to its range; revert on bad input."""
+ def on_submit(text):
+ try:
+ typed = float(text)
+ except ValueError:
+ set_textbox_value(textbox, slider.val, force=True)
+ return
+ slider.set_val(min(max(typed, vmin), vmax))
+ set_textbox_value(textbox, slider.val, force=True)
+ return on_submit
+
+ def make_slider_with_textbox(y, height, label, vmin, vmax, value):
+ """Create a labeled slider with an adjacent editable-number textbox, wired together."""
+ slider = Slider(axes(panel_left, y, slider_width, height), label, vmin, vmax, valinit=value)
+ slider.valtext.set_visible(False)
+ slider.label.set_fontsize(9)
+ textbox = TextBox(
+ axes(panel_left + slider_width + textbox_gap, y, textbox_width, height), "", initial=f"{value:.3f}"
+ )
+ textbox.text_disp.set_fontsize(9)
+ textbox.on_submit(make_on_submit(slider, textbox, vmin, vmax))
+ return slider, textbox
+
modes = ("hard", "gaussian", "feathered_disk")
+ mode_labels = {"hard": "Hard", "gaussian": "Gaussian", "feathered_disk": "Feathered"}
buttons = {}
for i, mode in enumerate(modes):
- ax_button = fig.add_axes((
- (panel_left + i * 1.70) / fig_width,
- button_bottom / fig_height,
- 1.45 / fig_width,
- button_height / fig_height,
- ))
- buttons[mode] = Button(ax_button, mode, color="0.96", hovercolor="0.88")
- buttons[mode].label.set_color("0.5")
- for spine in buttons[mode].ax.spines.values():
- spine.set_edgecolor("0.5")
+ ax_button = axes(panel_left + i * 1.95, button_bottom, 1.65, button_height)
+ buttons[mode] = Button(ax_button, mode_labels[mode], color="0.97", hovercolor="0.90")
+ style_button(buttons[mode])
+
+ ax_edge = axes(panel_left + panel_width - edge_width_btn, reset_bottom, edge_width_btn, reset_height)
+ edge_button = Button(ax_edge, "", color="0.97", hovercolor="0.90")
+ style_button(edge_button, fontsize=8)
+
+ ax_reset = axes(
+ panel_left + panel_width - edge_width_btn - top_button_gap - reset_width,
+ reset_bottom,
+ reset_width,
+ reset_height,
+ )
+ reset_button = Button(ax_reset, "Reset", color="0.97", hovercolor="0.90")
+ style_button(reset_button, fontsize=8)
- ax_softness = fig.add_axes((
- panel_left / fig_width,
- softness_bottom / fig_height,
- panel_width / fig_width,
- softness_height / fig_height,
- ))
- softness = Slider(ax_softness, "sigma", 0.0, 20.0, valinit=self.sigma)
+ softness_label = "edge_width" if initial["mask_type"] == "feathered_disk" else "sigma"
+ softness_value = initial["edge_width"] if initial["mask_type"] == "feathered_disk" else initial["sigma"]
+ softness, softness_textbox = make_slider_with_textbox(
+ softness_bottom, softness_height, softness_label, 0.0, 20.0, softness_value
+ )
specs = [
- ("cutoff_a", 0.05, 1.5, self.cutoff_a),
- ("cutoff_b", 0.05, 1.5, self.cutoff_a if self.cutoff_b is None else self.cutoff_b),
- ("offset_a", -1.0, 1.0, self.offset_a),
- ("offset_b", -1.0, 1.0, self.offset_b),
+ ("cutoff_a", 0.05, 1.5, initial["cutoff_a"]),
+ ("cutoff_b", 0.05, 1.5, initial["cutoff_b"]),
+ ("offset_a", -1.0, 1.0, initial["offset_a"]),
+ ("offset_b", -1.0, 1.0, initial["offset_b"]),
]
- sliders = {}
+ sliders, textboxes = {}, {}
for i, (name, vmin, vmax, value) in enumerate(specs):
y = slider_bottom + (len(specs) - 1 - i) * (slider_height + slider_gap)
- ax = fig.add_axes((
- panel_left / fig_width,
- y / fig_height,
- panel_width / fig_width,
- slider_height / fig_height,
- ))
- sliders[name] = Slider(ax, name, vmin, vmax, valinit=value)
+ sliders[name], textboxes[name] = make_slider_with_textbox(y, slider_height, name, vmin, vmax, value)
+
+ def style_active(button, active):
+ """Style button as selected (active) or grayed out."""
+ button.label.set_color("0.12" if active else "0.45")
+ for spine in button.ax.spines.values():
+ spine.set_edgecolor("0.12" if active else "0.55")
+ spine.set_linewidth(1.6 if active else 1.0)
+
+ def highlight(group, active_key):
+ for key, button in group.items():
+ style_active(button, key == active_key)
+
+ def update_edge_button():
+ """Refresh the edge-bias toggle label and active state."""
+ inward = self.edge_bias == "inward"
+ edge_button.label.set_text("Edge: inward" if inward else "Edge: centered")
+ style_active(edge_button, inward)
def update(_=None):
"""Refresh GUI state from sliders/buttons and redraw preview."""
for name, slider in sliders.items():
- # Update attributes based on slider values
+ # Update attributes based on slider values, and mirror the value into its textbox
setattr(self, name, slider.val)
- ax_softness.set_visible(self.mask_type != "hard")
+ set_textbox_value(textboxes[name], slider.val)
+ soft = self.mask_type != "hard"
+ softness.ax.set_visible(soft)
+ softness_textbox.ax.set_visible(soft)
+ ax_edge.set_visible(soft)
if self.mask_type == "gaussian":
softness.label.set_text("sigma")
self.sigma = softness.val
elif self.mask_type == "feathered_disk":
softness.label.set_text("edge_width")
self.edge_width = softness.val
- for mode, button in buttons.items():
- active = mode == self.mask_type
- button.label.set_color("black" if active else "0.5")
- for spine in button.ax.spines.values():
- spine.set_edgecolor("black" if active else "0.5")
- spine.set_linewidth(1.5 if active else 1.0)
+ set_textbox_value(softness_textbox, softness.val)
+ highlight(buttons, self.mask_type)
+ update_edge_button()
# shown is an AxesImage, this updates the displayed image
- shown.set_data(self._apply_with_mask(preview, self.mask()))
+ shown.set_data(self._apply_with_mask(preview, self.generate_mask()))
fig.canvas.draw_idle()
def set_mask_type(mode):
@@ -518,14 +841,172 @@ def set_mask_type(mode):
else:
update()
+ def toggle_edge_bias(_=None):
+ """Toggle edge_bias and refresh the preview."""
+ self.edge_bias = "center" if self.edge_bias == "inward" else "inward"
+ update()
+
+ def reset(_=None):
+ """Restore every control to its value when the GUI was opened."""
+ self.mask_type = initial["mask_type"]
+ self.edge_bias = initial["edge_bias"]
+ for slider in (*sliders.values(), softness):
+ slider.reset()
+ update()
+
for s in sliders.values():
s.on_changed(update)
softness.on_changed(update)
for mode, button in buttons.items():
button.on_clicked(lambda _, mode=mode: set_mask_type(mode))
+ edge_button.on_clicked(toggle_edge_bias)
+ reset_button.on_clicked(reset)
update()
plt.show()
- return self.mask()
+ self._print_interactive_mask_update(initial)
+ return self.generate_mask()
+
+ def _masker_params(self) -> dict[str, Union[str, float]]:
+ """Return the editable mask parameters shown in the interactive GUI."""
+ return {
+ "mask_type": self.mask_type,
+ "edge_bias": self.edge_bias,
+ "cutoff_a": self.cutoff_a,
+ "cutoff_b": self.cutoff_a if self.cutoff_b is None else self.cutoff_b,
+ "offset_a": self.offset_a,
+ "offset_b": self.offset_b,
+ "sigma": self.sigma,
+ "edge_width": self.edge_width,
+ }
+
+ def _relevant_params(self) -> dict[str, Union[str, float]]:
+ """Mask parameters that actually affect the current mask_type."""
+ drop = self._IRRELEVANT_PARAMS[self.mask_type]
+ return {name: value for name, value in self._masker_params().items() if name not in drop}
+
+ def _print_interactive_mask_update(self, before: dict[str, Union[str, float]]) -> None:
+ """Print a compact before/after summary after the GUI closes."""
+ rows = {
+ name: (self._format_param(before[name]), self._format_param(value))
+ for name, value in self._masker_params().items()
+ }
+ print("\n[SHINIER] StimulusMasker interactive update")
+ if all(old == new for old, new in rows.values()):
+ print(" No parameter changes.")
+ return
+ name_width = max(len(name) for name in rows)
+ old_width = max(len(old) for old, _ in rows.values())
+ for name, (old, new) in rows.items():
+ marker = "*" if old != new else " "
+ print(f" {marker} {name:<{name_width}} : {old:<{old_width}} -> {new}")
+
+ @staticmethod
+ def _format_param(value: Union[str, float]) -> str:
+ return f"{value:.6g}" if isinstance(value, (float, np.floating)) else str(value)
+
+ @staticmethod
+ def _require_positive(name: str, value: float) -> None:
+ if value <= 0: raise ValueError(f"{name} must be greater than 0.")
+ @staticmethod
+ def _require_nonnegative(name: str, value: float) -> None:
+ if value < 0: raise ValueError(f"{name} must be greater than or equal to 0.")
+ @staticmethod
+ def _require_choice(name: str, value: str, choices: tuple[str, ...]) -> None:
+ if value not in choices: raise ValueError(f"{name} must be one of {choices}.")
+ @staticmethod
+ def _is_single_image(stimuli: Any) -> bool: return isinstance(stimuli, np.ndarray) and stimuli.ndim in (2, 3)
+ @staticmethod
+ def _iter_images(stimuli: Union[np.ndarray, Iterable[np.ndarray]]) -> Iterable[np.ndarray]:
+ if isinstance(stimuli, np.ndarray) and stimuli.ndim < 4:
+ raise ValueError("stimuli must be a single 2D/3D image or an iterable/stack of images.")
+ return stimuli
+
+ def _save_one(
+ self,
+ image: np.ndarray,
+ path: Union[str, Path],
+ background: Optional[float],
+ output_dtype: Optional[Union[np.dtype, type]],
+ ) -> Path:
+ output_path = Path(path).expanduser()
+ if output_path.suffix == "":
+ output_path = output_path.with_suffix(".png")
+ masked = self.apply_mask(image, background=background, output_dtype=output_dtype, verbose=False)
+ self._save_image(masked, output_path)
+ self._log(f"StimulusMasker saved masked image: {output_path}")
+ return output_path
+
+ @staticmethod
+ def _log(message: str) -> None: print(f"[SHINIER] {message}")
+
+ @staticmethod
+ def _named_images(
+ stimuli: Union[Mapping[str, np.ndarray], Iterable[np.ndarray]],
+ names: Optional[Iterable[str]],
+ ) -> list[tuple[Union[str, Path], np.ndarray]]:
+ if isinstance(stimuli, Mapping):
+ items = list(stimuli.items())
+ StimulusMasker._ensure_unique_image_filenames(name for name, _ in items)
+ return items
+ images = list(stimuli)
+ output_names = list(names) if names is not None else [f"image_{idx:03d}.png" for idx in range(len(images))]
+ if len(output_names) != len(images):
+ raise ValueError("names must have the same length as stimuli.")
+ StimulusMasker._ensure_unique_image_filenames(output_names)
+ return list(zip(output_names, images))
+
+ @staticmethod
+ def _ensure_unique_image_filenames(names: Iterable[Union[str, Path]]) -> None:
+ filenames = [StimulusMasker._image_filename(name) for name in names]
+ if len(set(filenames)) != len(filenames):
+ raise ValueError("output filenames must be unique to avoid overwriting masked images.")
+
+ @staticmethod
+ def _validate_fit_request(
+ observed: np.ndarray,
+ mask_type: Union[MaskType, Literal["auto"]],
+ threshold: float,
+ ) -> None:
+ if observed.ndim != 2:
+ raise ValueError("mask must be a 2D array or a grayscale image.")
+ if not 0 < threshold < 1:
+ raise ValueError("threshold must be in (0, 1).")
+ if mask_type != "auto" and mask_type not in get_args(MaskType):
+ raise ValueError(f"mask_type must be 'auto' or one of {get_args(MaskType)}.")
+
+ @staticmethod
+ def _threshold_mask(observed: np.ndarray, threshold: float) -> np.ndarray:
+ foreground = observed >= threshold
+ if not np.any(foreground):
+ raise ValueError("mask has no foreground pixels at the selected threshold.")
+ if np.all(foreground):
+ raise ValueError("mask is entirely foreground at the selected threshold.")
+ return foreground
+
+ @staticmethod
+ def _fit_candidates(observed: np.ndarray, mask_type: Union[MaskType, Literal["auto"]]) -> list[MaskType]:
+ if mask_type != "auto":
+ return [mask_type]
+ if np.all(np.isin(observed, (0.0, 1.0))):
+ return ["hard"]
+ return ["feathered_disk", "gaussian"]
+
+ @classmethod
+ def _fit_mask_candidate(
+ cls,
+ observed: np.ndarray,
+ params: dict[str, Union[Tuple[int, int], float]],
+ mask_type: MaskType,
+ kwargs: dict[str, Any],
+ ) -> tuple["StimulusMasker", float]:
+ edge_width = cls._estimate_edge_width(observed, params)
+ candidate_kwargs = {**kwargs, **params, "image_size": observed.shape, "mask_type": mask_type}
+ if mask_type == "feathered_disk":
+ candidate_kwargs["edge_width"] = edge_width
+ elif mask_type == "gaussian":
+ candidate_kwargs["sigma"] = max(edge_width / 2.56, 0.0)
+ fit = cls(**candidate_kwargs)
+ return fit, float(np.mean((fit.generate_mask() - observed) ** 2))
def _normalize(self, image: np.ndarray) -> np.ndarray:
"""Return image as float64 in [0, 1]."""
@@ -537,25 +1018,163 @@ def _normalize(self, image: np.ndarray) -> np.ndarray:
out = out / 255.0
return np.clip(out, 0, 1)
- def _apply_with_mask(self, image: np.ndarray, mask: np.ndarray) -> np.ndarray:
+ def _apply_one_labeled(
+ self,
+ label: Union[str, int],
+ image: np.ndarray,
+ mask: np.ndarray,
+ background: Optional[float],
+ output_dtype: Optional[Union[np.dtype, type]],
+ ) -> np.ndarray:
+ """Apply the mask to one image of a batch, naming it in shape-mismatch errors."""
+ try:
+ return self._apply_with_mask(image, mask, background=background, output_dtype=output_dtype)
+ except ValueError as exc:
+ raise ValueError(f"stimulus {label!r} -- {exc}") from exc
+
+ def _apply_with_mask(
+ self,
+ image: np.ndarray,
+ mask: np.ndarray,
+ background: Optional[float] = None,
+ output_dtype: Optional[Union[np.dtype, type]] = None,
+ ) -> np.ndarray:
"""Helper for the interactive_mask method."""
stim = self._normalize(image)
- if stim.ndim == 2:
+ self._validate_image_shape(stim, mask)
+ if stim.ndim == 2 and not self.preserve_grayscale:
stim = np.repeat(stim[:, :, None], 3, axis=2)
mask = np.asarray(mask, dtype=np.float64)
stim = stim.copy()
- # Mask up to the first 3 channels; alpha, if present, is left unchanged.
- channels = min(3, stim.shape[2])
- stim[:, :, :channels] = mask[:, :, None] * (stim[:, :, :channels] - self.background) + self.background
- return self._as_output(np.clip(stim, 0, 1))
-
- def _as_output(self, image: np.ndarray) -> np.ndarray:
+ bg = self._normalize_background(self.background if background is None else background)
+ if stim.ndim == 2:
+ stim = mask * (stim - bg) + bg
+ else:
+ channels = min(3, stim.shape[2])
+ stim[:, :, :channels] = mask[:, :, None] * (stim[:, :, :channels] - bg) + bg
+ return self._as_output(np.clip(stim, 0, 1), output_dtype=output_dtype)
+
+ def _normalize_background(self, background: float) -> float:
+ """Return a background value normalized to [0, 1]."""
+ bg = float(background)
+ if bg > 1:
+ bg = bg / 255.0
+ return float(np.clip(bg, 0, 1))
+
+ def _as_output(self, image: np.ndarray, output_dtype: Optional[Union[np.dtype, type]] = None) -> np.ndarray:
"""Convert the masked image to output_dtype."""
- dtype = np.dtype(self.output_dtype)
+ dtype = np.dtype(self.output_dtype if output_dtype is None else output_dtype)
if np.issubdtype(dtype, np.integer):
image = np.rint(image * np.iinfo(dtype).max)
return image.astype(dtype)
+ def _cast_mask(self, mask: np.ndarray, dtype: Union[np.dtype, type]) -> np.ndarray:
+ """Cast a saved mask without destroying soft transition values."""
+ dtype = np.dtype(dtype)
+ if np.issubdtype(dtype, np.integer):
+ info = np.iinfo(dtype)
+ mask = np.rint(np.clip(mask, info.min, info.max))
+ return mask.astype(dtype)
+
+ def _mask_preview(self, mask: np.ndarray) -> np.ndarray:
+ """Convert mask values to an 8-bit image preview."""
+ arr = np.asarray(mask, dtype=np.float64)
+ if arr.size and 0 <= arr.min() and arr.max() <= 1:
+ arr = arr * 255
+ return self._cast_mask(arr, np.uint8)
+
+ def _save_image(self, image: np.ndarray, path: Path) -> None:
+ """Save a masked image through Pillow."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ arr = np.asarray(image)
+ if np.issubdtype(arr.dtype, np.floating):
+ arr = np.rint(np.clip(arr, 0, 1) * 255).astype(np.uint8)
+ elif arr.dtype != np.uint8:
+ arr = np.clip(arr, 0, 255).astype(np.uint8)
+ if arr.ndim == 3 and arr.shape[2] > 4:
+ arr = arr[:, :, :4]
+ Image.fromarray(arr).save(path)
+
+ @staticmethod
+ def _image_filename(name: Union[str, Path]) -> str:
+ path = Path(name)
+ return path.name if path.suffix else f"{path.name}.png"
+
+ def _mask_shape(self) -> tuple[int, int]:
+ """Return mask shape as ``(height, width)``."""
+ if isinstance(self.image_size, int):
+ return int(self.image_size), int(self.image_size)
+ if len(self.image_size) != 2:
+ raise ValueError("image_size must be an int or a (height, width) tuple.")
+ return int(self.image_size[0]), int(self.image_size[1])
+
+ def _validate_image_shape(self, image: np.ndarray, mask: np.ndarray) -> None:
+ """Raise a clear error when image and mask sizes differ."""
+ if image.ndim not in (2, 3):
+ raise ValueError("image must be a 2D grayscale or 3D channel-based array.")
+ if image.shape[:2] != mask.shape:
+ raise ValueError(f"Mask shape {mask.shape} does not match image shape {image.shape[:2]}.")
+
+ @staticmethod
+ def _load_mask_for_fit(mask: Union[np.ndarray, str, Path]) -> np.ndarray:
+ """Load and normalize a mask for parameter fitting."""
+ if isinstance(mask, (str, Path)):
+ path = Path(mask).expanduser()
+ if path.suffix.lower() == ".npy":
+ data = np.load(path)
+ else:
+ with Image.open(path) as image:
+ data = np.asarray(image.convert("L"))
+ else:
+ data = np.asarray(mask)
+ if data.ndim == 3:
+ data = data[..., :3].mean(axis=2)
+ data = data.astype(np.float64, copy=False)
+ data_min = float(np.nanmin(data))
+ data_max = float(np.nanmax(data))
+ if not np.isfinite(data_min) or not np.isfinite(data_max) or data_max <= data_min:
+ raise ValueError("mask must contain at least two finite values.")
+ return np.clip((data - data_min) / (data_max - data_min), 0, 1)
+
+ @staticmethod
+ def _estimate_ellipse_params(foreground: np.ndarray) -> dict[str, Union[Tuple[int, int], float]]:
+ """Estimate ellipse center and cutoff values from a thresholded mask."""
+ height, width = foreground.shape
+ ys, xs = np.nonzero(foreground)
+ decimals = StimulusMasker._fit_decimals(foreground.shape)
+ params = {
+ "cutoff_a": (xs.max() - xs.min() + 1) / max(width - 1, 1),
+ "cutoff_b": (ys.max() - ys.min() + 1) / max(height - 1, 1),
+ "offset_a": (xs.min() + xs.max()) / max(width - 1, 1) - 1.0,
+ "offset_b": (ys.min() + ys.max()) / max(height - 1, 1) - 1.0,
+ }
+ return {key: round(float(value), decimals) for key, value in params.items()}
+
+ @staticmethod
+ def _fit_decimals(shape: tuple[int, int]) -> int:
+ """Precision for fitted normalized parameters; roughly pixel-grid limited."""
+ return max(3, int(np.ceil(np.log10(max(shape) - 1)))) if max(shape) > 1 else 3
+
+ @staticmethod
+ def _estimate_edge_width(mask: np.ndarray, params: dict[str, Union[Tuple[int, int], float]]) -> float:
+ """Estimate feathered edge width in pixels from soft transition values."""
+ height, width = mask.shape
+ _, signed_distance = StimulusMasker._ellipse_geometry(
+ height, width, float(params["cutoff_a"]), float(params["cutoff_b"]),
+ float(params["offset_a"]), float(params["offset_b"]),
+ )
+ transition = (mask > 0.05) & (mask < 0.95) & np.isfinite(signed_distance)
+ if not np.any(transition):
+ return 0.0
+
+ sd = signed_distance[transition]
+ y_fit = mask[transition] - 0.5
+ denom = float(np.sum(sd * y_fit))
+ if denom <= 0:
+ return 0.0
+ edge_width = float(np.sum(sd * sd) / denom)
+ return max(edge_width, 0.0)
+
def _blur(self, image: np.ndarray) -> np.ndarray:
"""Apply a NumPy-only separable Gaussian blur."""
radius = int(3 * self.sigma)
diff --git a/tests/conftest.py b/tests/conftest.py
index 3bfc827..5136c09 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,7 +1,9 @@
# conftest.py
from __future__ import annotations
-import os, shutil, uuid
+import os
+os.environ.setdefault("MPLBACKEND", "Agg")
+import shutil, uuid
from pathlib import Path
from typing import Iterator
try:
diff --git a/tests/unit_tests/Utils_test.py b/tests/unit_tests/Utils_test.py
index 23b5b9a..32c0cbe 100644
--- a/tests/unit_tests/Utils_test.py
+++ b/tests/unit_tests/Utils_test.py
@@ -1,7 +1,11 @@
+import warnings
+
import numpy as np
import pytest
+from PIL import Image
from shinier.utils import (
+ StimulusMasker,
betce_gray,
classic_he_gray,
compute_ambe,
@@ -161,3 +165,264 @@ def test_pairwise_contrast_metrics_reject_shape_mismatch(metric) -> None:
def test_contrast_metric_bp2bpsim_rejects_invalid_bit_count() -> None:
with pytest.raises(ValueError, match="n_bits"):
compute_bp2bpsim(np.zeros((2, 2)), np.zeros((2, 2)), n_bits=0)
+
+
+def _rgb(value: int, shape: tuple[int, int] = (9, 9)) -> np.ndarray:
+ return np.full((*shape, 3), value, dtype=np.uint8)
+
+
+def _has_soft_values(values: np.ndarray, low: int = 128, high: int = 255) -> bool:
+ return bool(np.any((values > low) & (values < high)))
+
+
+def test_stimulus_masker_saves_masks_with_expected_dtype_range_and_warning(tmp_path) -> None:
+ hard = StimulusMasker(16, cutoff_a=0.6, mask_type="hard")
+ soft = StimulusMasker(33, cutoff_a=0.45, mask_type="feathered_disk", edge_width=8)
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("error")
+ hard.save_mask(tmp_path / "hard.npy")
+ with pytest.warns(RuntimeWarning, match="visualization purposes only"):
+ alpha_path = soft.save_mask(tmp_path / "soft")
+
+ alpha = np.load(alpha_path)
+ assert alpha_path == tmp_path / "soft.npy"
+ assert alpha.shape == (33, 33)
+ assert alpha.dtype == np.float32
+ assert 0 <= alpha.min() <= alpha.max() <= 1
+
+ with pytest.warns(RuntimeWarning, match="visualization purposes only"):
+ valued_path = soft.save_mask(tmp_path / "soft_valued.npy", dtype=np.uint8, inside_value=255, outside_value=128)
+ with pytest.warns(RuntimeWarning, match="visualization purposes only"):
+ preview_path = soft.save_mask(tmp_path / "soft_preview.png", inside_value=255, outside_value=128)
+
+ valued = np.load(valued_path)
+ preview = np.asarray(Image.open(preview_path))
+ for saved in (valued, preview):
+ values = np.unique(saved)
+ assert saved.dtype == np.uint8
+ assert 128 in values and 255 in values
+ assert _has_soft_values(values)
+
+
+@pytest.mark.parametrize("background", [128, 0.5])
+def test_stimulus_masker_applies_background_and_soft_transition(background) -> None:
+ hard = StimulusMasker(9, cutoff_a=0.5, mask_type="hard", background=background, output_dtype=np.uint8)
+ masked = hard.apply_mask(_rgb(255))
+ np.testing.assert_array_equal(masked[0, 0], [128, 128, 128])
+ np.testing.assert_array_equal(masked[4, 4], [255, 255, 255])
+
+ soft = StimulusMasker(33, 0.45, mask_type="feathered_disk", edge_width=8, background=128, output_dtype=np.uint8)
+ values = np.unique(soft.apply_mask(_rgb(255, (33, 33)))[:, :, 0])
+ assert 128 in values and 255 in values
+ assert _has_soft_values(values)
+
+
+@pytest.mark.parametrize("mask_type,softness", [("feathered_disk", {"edge_width": 8}), ("gaussian", {"sigma": 3})])
+def test_stimulus_masker_inward_edge_bias_never_exceeds_hard_footprint(mask_type, softness) -> None:
+ common = dict(image_size=65, cutoff_a=0.45, cutoff_b=0.6, offset_a=0.05, offset_b=-0.1)
+ hard_exterior = StimulusMasker(**common, mask_type="hard").generate_mask() == 0
+ inward = StimulusMasker(**common, mask_type=mask_type, edge_bias="inward", **softness).generate_mask()
+ centered = StimulusMasker(**common, mask_type=mask_type, edge_bias="center", **softness).generate_mask()
+
+ np.testing.assert_allclose(inward[hard_exterior], 0.0, atol=1e-6)
+ assert centered[hard_exterior].max() > 1e-3
+
+
+def test_stimulus_masker_apply_accepts_single_batch_mapping_and_grayscale(capsys) -> None:
+ masker = StimulusMasker(9, 0.5, mask_type="hard", background=128, output_dtype=np.uint8)
+ image, black = _rgb(255), _rgb(0)
+
+ single = masker.apply_mask(image)
+ batch = masker.apply_mask([image, black], verbose=False)
+ stack = masker.apply_mask(np.stack([image, black]), verbose=False)
+ mapping = masker.apply_mask({"face_a.png": image, "face_b.png": black}, verbose=False)
+
+ assert "1 image(s)" in capsys.readouterr().out
+ assert isinstance(single, np.ndarray)
+ assert [arr.shape for arr in batch] == [image.shape, image.shape]
+ assert [arr.shape for arr in stack] == [image.shape, image.shape]
+ assert set(mapping) == {"face_a.png", "face_b.png"}
+ np.testing.assert_array_equal(batch[0], single)
+ np.testing.assert_array_equal(mapping["face_a.png"], single)
+
+ gray = StimulusMasker(9, 0.5, mask_type="hard", output_dtype=np.uint8, preserve_grayscale=True)
+ masked_gray = gray.apply_mask(np.full((9, 9), 255, dtype=np.uint8), background=128, verbose=False)
+ assert masked_gray.shape == (9, 9)
+ assert masked_gray[0, 0] == 128
+
+
+def test_stimulus_masker_save_masked_stim_paths_logs_and_safety(capsys, tmp_path) -> None:
+ image, bad = _rgb(255), _rgb(0, (8, 8))
+ masker = StimulusMasker(9, 0.5, mask_type="hard", output_dtype=np.float32)
+
+ single = masker.save_masked_stim(image, tmp_path / "face", background=128, output_dtype=np.uint8)
+ paths = masker.save_masked_stim({"face_a.png": image, "face_b.png": _rgb(0)}, tmp_path / "batch", background=128)
+
+ assert capsys.readouterr().out.count("[SHINIER]") == 2
+ assert single == tmp_path / "face.png"
+ assert [p.name for p in paths] == ["face_a.png", "face_b.png"]
+ np.testing.assert_array_equal(np.asarray(Image.open(single))[0, 0], [128, 128, 128])
+ assert masker.output_dtype is np.float32
+
+ with pytest.raises(ValueError, match=r"stimulus 'bad_one.png' -- Mask shape"):
+ masker.save_masked_stim({"good.png": image, "bad_one.png": bad}, tmp_path)
+ with pytest.raises(ValueError, match="output filenames must be unique"):
+ masker.save_masked_stim([image, image], tmp_path, names=["same", "same.png"])
+ with pytest.raises(ValueError, match="output filenames must be unique"):
+ masker.save_masked_stim({"folder/same.png": image, "same.png": image}, tmp_path)
+
+
+def test_stimulus_masker_defaults_validation_and_error_labels() -> None:
+ masker = StimulusMasker(9, 0.5)
+ assert masker.mask_type == "hard"
+ assert masker.edge_bias == "center"
+ np.testing.assert_array_equal(np.unique(masker.generate_mask()), [0.0, 1.0])
+
+ with pytest.raises(ValueError, match=r"Mask shape \(9, 9\) does not match image shape \(8, 8\)"):
+ masker.apply_mask(_rgb(0, (8, 8)), verbose=False)
+ with pytest.raises(ValueError, match=r"stimulus 'bad_one' -- Mask shape"):
+ masker.apply_mask({"good": _rgb(0), "bad_one": _rgb(0, (8, 8))}, verbose=False)
+ with pytest.raises(ValueError, match=r"stimulus 1 -- Mask shape"):
+ masker.apply_mask([_rgb(0), _rgb(0, (8, 8))], verbose=False)
+
+
+@pytest.mark.parametrize(
+ ("mask_type", "kwargs", "expected", "unexpected"),
+ [
+ ("hard", {}, ("mask_type=hard",), ("edge_bias", "sigma=", "edge_width=")),
+ ("gaussian", {"sigma": 3}, ("mask_type=gaussian", "edge_bias=", "sigma=3"), ("edge_width=",)),
+ ("feathered_disk", {"edge_width": 4}, ("mask_type=feathered_disk", "edge_bias=", "edge_width=4"), ("sigma=",)),
+ ],
+)
+def test_stimulus_masker_verbose_logs_relevant_params(capsys, mask_type, kwargs, expected, unexpected) -> None:
+ StimulusMasker(9, 0.5, cutoff_b=0.6, mask_type=mask_type, **kwargs).apply_mask(_rgb(0))
+ out = capsys.readouterr().out
+ assert "1 image(s)" in out and "cutoff_a=0.5" in out
+ assert all(token in out for token in expected)
+ assert not any(token in out for token in unexpected)
+
+
+@pytest.mark.parametrize(
+ ("kwargs", "message"),
+ [
+ ({"image_size": 0, "cutoff_a": 0.5}, "image_size"),
+ ({"image_size": 9, "cutoff_a": 0}, "cutoff_a"),
+ ({"image_size": 9, "cutoff_a": 0.5, "cutoff_b": 0}, "cutoff_b"),
+ ({"image_size": 9, "cutoff_a": 0.5, "sigma": -1}, "sigma"),
+ ({"image_size": 9, "cutoff_a": 0.5, "edge_width": -1}, "edge_width"),
+ ({"image_size": 9, "cutoff_a": 0.5, "mask_type": "bad"}, "mask_type"),
+ ({"image_size": 9, "cutoff_a": 0.5, "edge_bias": "bad"}, "edge_bias"),
+ ],
+)
+def test_stimulus_masker_rejects_invalid_parameters(kwargs, message) -> None:
+ with pytest.raises(ValueError, match=message):
+ StimulusMasker(**kwargs)
+
+
+@pytest.mark.parametrize(
+ ("original", "expected_type", "tolerance"),
+ [
+ (
+ StimulusMasker((129, 161), 0.52, cutoff_b=0.74, offset_a=0.08, offset_b=-0.06, mask_type="hard"),
+ "hard",
+ 0.01,
+ ),
+ (
+ StimulusMasker(
+ 129,
+ 0.52,
+ cutoff_b=0.74,
+ offset_a=0.08,
+ offset_b=-0.06,
+ mask_type="feathered_disk",
+ edge_width=7,
+ ),
+ "feathered_disk",
+ 0.02,
+ ),
+ ],
+)
+def test_stimulus_masker_from_mask_recovers_parameters(original, expected_type, tolerance) -> None:
+ fitted, error = StimulusMasker.from_mask(original.generate_mask(), return_error=True)
+ assert fitted.mask_type == expected_type
+ assert fitted.cutoff_a == pytest.approx(original.cutoff_a, abs=tolerance)
+ assert fitted.cutoff_b == pytest.approx(original.cutoff_b, abs=tolerance)
+ assert fitted.offset_a == pytest.approx(original.offset_a, abs=tolerance)
+ assert fitted.offset_b == pytest.approx(original.offset_b, abs=tolerance)
+ assert error < 0.01
+ if expected_type == "feathered_disk":
+ assert fitted.edge_width == pytest.approx(original.edge_width, abs=0.5)
+
+
+def test_stimulus_masker_from_mask_loads_npy_and_rejects_constant_mask(tmp_path) -> None:
+ original = StimulusMasker(33, 0.5, cutoff_b=0.7, offset_b=0.1, mask_type="hard")
+ path = tmp_path / "mask.npy"
+ np.save(path, original.generate_mask())
+
+ fitted = StimulusMasker.from_mask(path)
+ assert fitted.mask_type == "hard"
+ assert fitted.image_size == (33, 33)
+ assert fitted.cutoff_a == pytest.approx(original.cutoff_a, abs=0.05)
+ assert fitted.cutoff_b == pytest.approx(original.cutoff_b, abs=0.05)
+ assert fitted.offset_b == pytest.approx(original.offset_b, abs=0.05)
+
+ with pytest.raises(ValueError, match="at least two finite values"):
+ StimulusMasker.from_mask(np.ones((8, 8)))
+
+
+def test_stimulus_masker_from_interactive_mask_builds_from_image(monkeypatch) -> None:
+ def fake_interactive_mask(self, _image):
+ self.offset_b = 0.25
+ return self.generate_mask()
+
+ monkeypatch.setattr(StimulusMasker, "interactive_mask", fake_interactive_mask)
+ masker = StimulusMasker.from_interactive_mask(np.zeros((8, 9, 3), dtype=np.uint8), cutoff_a=0.6)
+ assert masker.image_size == (8, 9)
+ assert masker.cutoff_a == 0.6
+ assert masker.offset_b == 0.25
+
+
+def test_stimulus_masker_interactive_mask_widgets(monkeypatch) -> None:
+ # Backend is forced to Agg in conftest.py, before pyplot is ever imported anywhere
+ # in the session -- do not call matplotlib.use() here (see conftest.py for why).
+ import matplotlib.pyplot as plt
+ import matplotlib.widgets as widgets
+
+ monkeypatch.setattr(plt, "show", lambda *args, **kwargs: None)
+ created = {"Button": [], "TextBox": []}
+ for name in created:
+ original = getattr(widgets, name)
+
+ def tracker_class(original, name):
+ class Tracker(original):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ created[name].append(self)
+ return Tracker
+
+ monkeypatch.setattr(widgets, name, tracker_class(original, name))
+
+ masker = StimulusMasker(32, 0.5, cutoff_b=0.6, mask_type="feathered_disk", edge_width=5)
+ masker.interactive_mask(np.zeros((32, 32, 3), dtype=np.uint8))
+
+ cutoff_a_textbox = created["TextBox"][1]
+ edge_button = next(button for button in created["Button"] if "Edge" in button.label.get_text())
+ reset_button = next(button for button in created["Button"] if button.label.get_text() == "Reset")
+
+ cutoff_a_textbox.set_val("0.9")
+ cutoff_a_textbox._observers.process("submit", cutoff_a_textbox.text)
+ assert masker.cutoff_a == pytest.approx(0.9)
+
+ cutoff_a_textbox.set_val("99")
+ cutoff_a_textbox._observers.process("submit", cutoff_a_textbox.text)
+ assert masker.cutoff_a == pytest.approx(1.5)
+
+ edge_button._observers.process("clicked", None)
+ assert masker.edge_bias == "inward"
+
+ reset_button._observers.process("clicked", None)
+ assert masker.cutoff_a == pytest.approx(0.5)
+ assert masker.edge_bias == "center"
+ assert masker.mask_type == "feathered_disk"
+ assert cutoff_a_textbox.text == "0.500"