Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@
[![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](LICENSE)
[![Python versions](https://img.shields.io/pypi/pyversions/shinier)](https://pypi.org/project/shinier/)
[![PyPI version](https://img.shields.io/pypi/v/shinier.svg)](https://pypi.org/project/shinier/)
[![Documentation Status](https://readthedocs.org/projects/shinier/badge/?version=latest)](https://shinier.readthedocs.io/)
[![DOI](https://img.shields.io/badge/DOI-10.1016%2Fj.softx.2026.102884-blue.svg)](https://doi.org/10.1016/j.softx.2026.102884)
[![Tests](https://github.com/Charestlab/shinier/actions/workflows/tests.yml/badge.svg)](https://github.com/Charestlab/shinier/actions/workflows/tests.yml)
---

## Overview

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).
Expand Down Expand Up @@ -153,5 +159,5 @@ See [LICENSE](LICENSE) for more information.
---
<p align="center">
<strong>Code developed by Nicolas Dupuis-Roy and Mathias Salvas-Hébert </strong><br>
<em>Version 0.2.2 - Complete technical documentation</em>
<em>Version 0.2.3 - Complete technical documentation</em>
</p>
3 changes: 3 additions & 0 deletions documentation/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
[![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](../LICENSE)
[![Python](https://img.shields.io/badge/python-3.9%2B-blue.svg)]()
[![PyPI version](https://img.shields.io/pypi/v/shinier.svg)](https://pypi.org/project/shinier/)
[![Documentation Status](https://readthedocs.org/projects/shinier/badge/?version=latest)](https://shinier.readthedocs.io/en/latest/)
[![DOI](https://img.shields.io/badge/DOI-10.1016%2Fj.softx.2026.102884-blue.svg)](https://doi.org/10.1016/j.softx.2026.102884)
[![Tests](https://github.com/Charestlab/shinier/actions/workflows/tests.yml/badge.svg)](https://github.com/Charestlab/shinier/actions/workflows/tests.yml)
---

# Contributing to SHINIER
Expand Down
3 changes: 3 additions & 0 deletions documentation/demos.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
[![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](../LICENSE)
[![Python](https://img.shields.io/badge/python-3.9%2B-blue.svg)]()
[![PyPI version](https://img.shields.io/pypi/v/shinier.svg)](https://pypi.org/project/shinier/)
[![Documentation Status](https://readthedocs.org/projects/shinier/badge/?version=latest)](https://shinier.readthedocs.io/en/latest/)
[![DOI](https://img.shields.io/badge/DOI-10.1016%2Fj.softx.2026.102884-blue.svg)](https://doi.org/10.1016/j.softx.2026.102884)
[![Tests](https://github.com/Charestlab/shinier/actions/workflows/tests.yml/badge.svg)](https://github.com/Charestlab/shinier/actions/workflows/tests.yml)
---

# Demos / How-to-use
Expand Down
69 changes: 42 additions & 27 deletions documentation/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
[![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](../LICENSE)
[![Python](https://img.shields.io/badge/python-3.9%2B-blue.svg)]()
[![PyPI version](https://img.shields.io/pypi/v/shinier.svg)](https://pypi.org/project/shinier/)
[![Documentation Status](https://readthedocs.org/projects/shinier/badge/?version=latest)](https://shinier.readthedocs.io/en/latest/)
[![DOI](https://img.shields.io/badge/DOI-10.1016%2Fj.softx.2026.102884-blue.svg)](https://doi.org/10.1016/j.softx.2026.102884)
[![Tests](https://github.com/Charestlab/shinier/actions/workflows/tests.yml/badge.svg)](https://github.com/Charestlab/shinier/actions/workflows/tests.yml)
---

# Documentation
Expand Down Expand Up @@ -295,13 +298,12 @@ mode = 2 # hist_match only
![](figures/sliding_puzzle.png)

**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)**

Expand Down Expand Up @@ -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)
```

![Dynamic StimulusMasker GUI demo](readthedocs/_static/dynamic_stim_masker.gif)

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)
```

---

<a id="implemented-algorithms"></a>
Expand Down Expand Up @@ -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.
Expand All @@ -937,20 +952,20 @@ Composite modes (5-8) apply **two sequential transformations** (e.g., spectrum m
<a id="additional-resources"></a>
## 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 <demos>`:

- 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/).

---

<p align="center">
<strong>Code developed by Nicolas Dupuis-Roy and Mathias Salvas-Hébert </strong><br>
<em>Version 0.2.2 - Complete technical documentation</em>
<em>Version 0.2.3 - Complete technical documentation</em>
</p>
2 changes: 1 addition & 1 deletion documentation/readthedocs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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__
```

Expand Down
2 changes: 1 addition & 1 deletion documentation/readthedocs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
try:
from shinier import __version__
except Exception:
__version__ = "0.2.2"
__version__ = "0.2.3"

version = __version__
release = __version__
Expand Down
1 change: 1 addition & 0 deletions documentation/readthedocs/project-links.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
Useful external links for SHINIER:

- Article: [SHINIER (ScienceDirect)](https://www.sciencedirect.com/science/article/pii/S2352711026003754)
- Documentation: <https://shinier.readthedocs.io/>
- PyPI: <https://pypi.org/project/shinier/>
- GitHub: <https://github.com/Charestlab/shinier>
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -33,6 +33,7 @@ classifiers = [

[project.urls]
Homepage = "https://github.com/Charestlab/shinier"
Documentation = "https://shinier.readthedocs.io/"

[project.optional-dependencies]
dev = [
Expand Down
24 changes: 14 additions & 10 deletions src/shinier/SHINIER.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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=[
Expand All @@ -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=[
Expand Down Expand Up @@ -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("╔══════════════════════════════════════════════════════╗")
Expand Down
2 changes: 1 addition & 1 deletion src/shinier/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading