diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 47e6cd1a2b89..f71744393efb 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -2805,7 +2805,8 @@ def halfrange(self, halfrange): self.vmax = self.vcenter + abs(halfrange) -def make_norm_from_scale(scale_cls, base_norm_cls=None, *, init=None): +def make_norm_from_scale(scale_cls, base_norm_cls=None, *, init=None, + norm_before_trf=False): """ Decorator for building a `.Normalize` subclass from a `~.scale.ScaleBase` subclass. @@ -2837,7 +2838,8 @@ class norm_cls(Normalize): """ if base_norm_cls is None: - return functools.partial(make_norm_from_scale, scale_cls, init=init) + return functools.partial(make_norm_from_scale, scale_cls, init=init, + norm_before_trf=norm_before_trf) if isinstance(scale_cls, functools.partial): scale_args = scale_cls.args @@ -2851,13 +2853,13 @@ def init(vmin=None, vmax=None, clip=False): pass return _make_norm_from_scale( scale_cls, scale_args, scale_kwargs_items, - base_norm_cls, inspect.signature(init)) + base_norm_cls, inspect.signature(init), norm_before_trf) @functools.cache def _make_norm_from_scale( scale_cls, scale_args, scale_kwargs_items, - base_norm_cls, bound_init_signature, + base_norm_cls, bound_init_signature, norm_before_trf ): """ Helper for `make_norm_from_scale`. @@ -2889,7 +2891,7 @@ def __reduce__(self): pass return (_picklable_norm_constructor, (scale_cls, scale_args, scale_kwargs_items, - base_norm_cls, bound_init_signature), + base_norm_cls, bound_init_signature, norm_before_trf), vars(self)) def __init__(self, *args, **kwargs): @@ -2918,6 +2920,14 @@ def __call__(self, value, clip=None): clip = self.clip if clip: value = np.clip(value, self.vmin, self.vmax) + + if norm_before_trf: + value -= self.vmin + value /= (self.vmax - self.vmin) + t_value = self._trf.transform(value).reshape(np.shape(value)) + t_value = np.ma.masked_invalid(t_value, copy=False) + return t_value[0] if is_scalar else t_value + t_value = self._trf.transform(value).reshape(np.shape(value)) t_vmin, t_vmax = self._trf.transform([self.vmin, self.vmax]) if not np.isfinite([t_vmin, t_vmax]).all(): @@ -2932,10 +2942,17 @@ def inverse(self, value): raise ValueError("Not invertible until scaled") if self.vmin > self.vmax: raise ValueError("vmin must be less or equal to vmax") + value, is_scalar = self.process_value(value) + + if norm_before_trf: + value = self._trf.inverted().transform(value).reshape(np.shape(value)) + rescaled = value * (self.vmax - self.vmin) + rescaled += self.vmin + return rescaled[0] if is_scalar else rescaled + t_vmin, t_vmax = self._trf.transform([self.vmin, self.vmax]) if not np.isfinite([t_vmin, t_vmax]).all(): raise ValueError("Invalid vmin or vmax") - value, is_scalar = self.process_value(value) rescaled = value * (t_vmax - t_vmin) rescaled += t_vmin value = (self._trf @@ -3084,6 +3101,10 @@ def linear_width(self, value): self._scale.linear_width = value +@make_norm_from_scale( + scale.PowerScale, + init=lambda gamma, vmin=None, vmax=None, clip=False: None, + norm_before_trf=True) class PowerNorm(Normalize): r""" Linearly map a given value to the 0-1 range and then apply @@ -3120,56 +3141,13 @@ class PowerNorm(Normalize): For input values below *vmin*, gamma is set to one. """ - def __init__(self, gamma, vmin=None, vmax=None, clip=False): - super().__init__(vmin, vmax, clip) - self.gamma = gamma - - def __call__(self, value, clip=None): - if clip is None: - clip = self.clip - - result, is_scalar = self.process_value(value) - - self.autoscale_None(result) - gamma = self.gamma - vmin, vmax = self.vmin, self.vmax - if vmin > vmax: - raise ValueError("minvalue must be less than or equal to maxvalue") - elif vmin == vmax: - result.fill(0) - else: - if clip: - mask = np.ma.getmask(result) - result = np.ma.array(np.clip(result.filled(vmax), vmin, vmax), - mask=mask) - resdat = result.data - resdat -= vmin - resdat /= (vmax - vmin) - resdat[resdat > 0] = np.power(resdat[resdat > 0], gamma) - - result = np.ma.array(resdat, mask=result.mask, copy=False) - if is_scalar: - result = result[0] - return result - - def inverse(self, value): - if not self.scaled(): - raise ValueError("Not invertible until scaled") - - result, is_scalar = self.process_value(value) - - gamma = self.gamma - vmin, vmax = self.vmin, self.vmax - - resdat = result.data - resdat[resdat > 0] = np.power(resdat[resdat > 0], 1 / gamma) - resdat *= (vmax - vmin) - resdat += vmin + @property + def gamma(self): + return self._scale.gamma - result = np.ma.array(resdat, mask=result.mask, copy=False) - if is_scalar: - result = result[0] - return result + @gamma.setter + def gamma(self, value): + self._scale.gamma = value class BoundaryNorm(Normalize): diff --git a/lib/matplotlib/colors.pyi b/lib/matplotlib/colors.pyi index 07bf01b8f995..99dc64e4495a 100644 --- a/lib/matplotlib/colors.pyi +++ b/lib/matplotlib/colors.pyi @@ -339,14 +339,16 @@ def make_norm_from_scale( scale_cls: type[scale.ScaleBase], base_norm_cls: type[Normalize], *, - init: Callable | None = ... + init: Callable | None = ..., + norm_before_trf: bool = ..., ) -> type[Normalize]: ... @overload def make_norm_from_scale( scale_cls: type[scale.ScaleBase], base_norm_cls: None = ..., *, - init: Callable | None = ... + init: Callable | None = ..., + norm_before_trf: bool = ..., ) -> Callable[[type[Normalize]], type[Normalize]]: ... class FuncNorm(Normalize): @@ -389,7 +391,6 @@ class AsinhNorm(Normalize): def linear_width(self, value: float) -> None: ... class PowerNorm(Normalize): - gamma: float def __init__( self, gamma: float, @@ -397,6 +398,10 @@ class PowerNorm(Normalize): vmax: float | None = ..., clip: bool = ..., ) -> None: ... + @property + def gamma(self) -> float: ... + @gamma.setter + def gamma(self, value: float) -> None: ... class BoundaryNorm(Normalize): boundaries: np.ndarray diff --git a/lib/matplotlib/scale.py b/lib/matplotlib/scale.py index f6ccc42442d6..a3056d79b9e9 100644 --- a/lib/matplotlib/scale.py +++ b/lib/matplotlib/scale.py @@ -17,6 +17,7 @@ "log" `LogScale` `LogTransform` `InvertedLogTransform` "logit" `LogitScale` `LogitTransform` `LogisticTransform` "symlog" `SymmetricalLogScale` `SymmetricalLogTransform` `InvertedSymmetricalLogTransform` +"power" `PowerScale` `PowerTransform` `InvertedPowerTransform` ============= ===================== ================================ ================================= A user will often only use the scale name, e.g. when setting the scale through @@ -282,6 +283,109 @@ def set_default_locators_and_formatters(self, axis): axis.set_minor_locator(NullLocator()) +class PowerTransform(Transform): + """ + A simple power transformation used by `.PowerScale`. + + This transformation applies a power-law scaling to positive values, while + nonpositive values remain unchanged. + """ + input_dims = output_dims = 1 + + def __init__(self, gamma): + """ + Parameters + ---------- + gamma : float + Power law exponent. + """ + super().__init__() + self.gamma = gamma + + def __str__(self): + return "{}(gamma={})".format( + type(self).__name__, self.gamma) + + def transform_non_affine(self, values): + with np.errstate(divide="ignore", invalid="ignore"): + nonpos = ~(values > 0) + out = np.power(values, self.gamma) + out[nonpos] = values[nonpos] + return out + + def inverted(self): + return InvertedPowerTransform(self.gamma) + + +class InvertedPowerTransform(Transform): + """ + The inverse of the `.PowerTransform`. + + This transformation applies an inverse power-law scaling to positive values, + while nonpositive values remain unchanged. + """ + input_dims = output_dims = 1 + + def __init__(self, gamma): + """ + Parameters + ---------- + gamma : float + Power law exponent. + """ + super().__init__() + if gamma == 0: + raise ValueError('gamma cannot be 0') + self.gamma = gamma + + def transform_non_affine(self, values): + with np.errstate(divide="ignore", invalid="ignore"): + nonpos = ~(values > 0) + out = np.power(values, 1.0 / self.gamma) + out[nonpos] = values[nonpos] + return out + + def inverted(self): + return PowerTransform(self.gamma) + + +class PowerScale(ScaleBase): + """ + A standard power scale + """ + name = 'power' + + @_make_axis_parameter_optional + def __init__(self, axis=None, *, gamma=0.5): + """ + Parameters + ---------- + axis : `~matplotlib.axis.Axis` + The axis for the scale. + gamma : float, default: 0.5 + Power law exponent. + """ + self._transform = PowerTransform(gamma) + + gamma = property(lambda self: self._transform.gamma) + + def get_transform(self): + """Return the `.PowerTransform` associated with this scale.""" + return self._transform + + def set_default_locators_and_formatters(self, axis): + # docstring inherited + axis.set_major_locator(AutoLocator()) + axis.set_major_formatter(ScalarFormatter()) + axis.set_minor_formatter(NullFormatter()) + # update the minor locator for x and y axis based on rcParams + if (axis.axis_name == 'x' and mpl.rcParams['xtick.minor.visible'] or + axis.axis_name == 'y' and mpl.rcParams['ytick.minor.visible']): + axis.set_minor_locator(AutoMinorLocator()) + else: + axis.set_minor_locator(NullLocator()) + + class LogTransform(Transform): input_dims = output_dims = 1 @@ -807,6 +911,7 @@ def limit_range_for_scale(self, vmin, vmax, minpos): 'logit': LogitScale, 'function': FuncScale, 'functionlog': FuncScaleLog, + 'power': PowerScale, } # caching of signature info @@ -821,6 +926,7 @@ def limit_range_for_scale(self, vmin, vmax, minpos): 'logit': True, 'function': True, 'functionlog': True, + 'power': True, } diff --git a/lib/matplotlib/scale.pyi b/lib/matplotlib/scale.pyi index ba9f269b8c78..6fe8dbfa9464 100644 --- a/lib/matplotlib/scale.pyi +++ b/lib/matplotlib/scale.pyi @@ -40,6 +40,29 @@ class FuncScale(ScaleBase): ], ) -> None: ... +class PowerTransform(Transform): + def __init__(self, gamma: float) -> None: ... + def __str__(self) -> str: ... + def transform_non_affine(self, values: ArrayLike) -> ArrayLike: ... + def inverted(self) -> InvertedPowerTransform: ... + +class InvertedPowerTransform(Transform): + def __init__(self, gamma: float) -> None: ... + def transform_non_affine(self, values: ArrayLike) -> ArrayLike: ... + def inverted(self) -> PowerTransform: ... + +class PowerScale(ScaleBase): + name: str + def __init__( + self, + axis: Axis | None = ..., + *, + gamma: float = ..., + ) -> None: ... + @property + def gamma(self) -> float: ... + def get_transform(self) -> PowerTransform: ... + class LogTransform(Transform): input_dims: int output_dims: int diff --git a/lib/matplotlib/tests/baseline_images/test_scale/power_scale.png b/lib/matplotlib/tests/baseline_images/test_scale/power_scale.png new file mode 100644 index 000000000000..bcc59442ac84 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_scale/power_scale.png differ diff --git a/lib/matplotlib/tests/test_scale.py b/lib/matplotlib/tests/test_scale.py index f98e083d84a0..76f59c9b6746 100644 --- a/lib/matplotlib/tests/test_scale.py +++ b/lib/matplotlib/tests/test_scale.py @@ -4,7 +4,7 @@ from matplotlib.scale import ( AsinhScale, AsinhTransform, LogTransform, InvertedLogTransform, - SymmetricalLogTransform) + SymmetricalLogTransform, PowerTransform) import matplotlib.scale as mscale from matplotlib.ticker import ( AsinhLocator, AutoLocator, LogFormatterSciNotation, @@ -118,7 +118,7 @@ def test_logscale_mask(): def test_extra_kwargs_raise(): fig, ax = plt.subplots() - for scale in ['linear', 'log', 'symlog']: + for scale in ['linear', 'log', 'symlog', 'power']: with pytest.raises(TypeError): ax.set_yscale(scale, foo='mask') @@ -371,3 +371,50 @@ def set_default_locators_and_formatters(self, axis): # cleanup - there's no public unregister_scale() del mscale._scale_mapping["custom"] del mscale._scale_has_axis_parameter["custom"] + + +@image_comparison(['power_scale.png'], remove_text=True, tol=0.02, style='mpl20') +def test_power_scale(): + xs = np.linspace(-5, 5, 100) + fig, (ax1, ax2) = plt.subplots(1, 2) + ax1.scatter(xs, xs, s=0.1) + ax1.set_yscale('power', gamma=2) + ax2.plot(xs, xs) + ax2.set_yscale('power', gamma=0.1) + + +def test_power_transform(): + for gamma in (-2, -1, -0.5, 0, 0.5, 1, 2): + p = PowerTransform(gamma) + + x = np.array([-2, -1, 0, 1, 2, 3, np.nan]) + expected_x_transformed = [-2, -1, 0, 1, 2**gamma, 3**gamma, np.nan] + + x_transformed = p.transform_non_affine(x) + assert_allclose(x_transformed, expected_x_transformed) + + +def test_power_mask_nan(): + for gamma in (-2, -1, -0.5, 0.5, 1, 2): + p = PowerTransform(gamma) + pti = p.inverted() + + x = np.arange(-1.5, 5, 0.5) + out = pti.transform_non_affine(p.transform_non_affine(x)) + assert_allclose(out, x) + assert type(out) is type(x) + + x[4] = np.nan + out = pti.transform_non_affine(p.transform_non_affine(x)) + assert_allclose(out, x) + assert type(out) is type(x) + + x = np.ma.array(x) + out = pti.transform_non_affine(p.transform_non_affine(x)) + assert_allclose(out, x) + assert type(out) is type(x) + + x[3] = np.ma.masked + out = pti.transform_non_affine(p.transform_non_affine(x)) + assert_allclose(out, x) + assert type(out) is type(x)