Skip to content

Conversation

@Sreekanth-M8
Copy link
Collaborator

@Sreekanth-M8 Sreekanth-M8 commented Jul 15, 2025

PR summary

closes 20355

Added PowerTransform and PowerScale, to make PowerNorm using the make_norm_from_scale decorator.

In PowerNorm the expected behavior is normalizing before transformation, unlike other scales which transform and then normalize. So I added a optional boolean argument norm_before_trf to make_norm_from_scale which controls whether normalization occurs before transformation or not.

PR checklist

Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for opening your first PR into Matplotlib!

If you have not heard from us in a week or so, please leave a new comment below and that should bring it to our attention. Most of our reviewers are volunteers and sometimes things fall through the cracks.

You can also join us on gitter for real-time discussion.

For details on testing, writing docs, and our review process, please see the developer guide

We strive to be a welcoming and open project. Please follow our Code of Conduct.

@r3kste r3kste self-requested a review July 28, 2025 10:35
Copy link
Owner

@r3kste r3kste left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a few additional things we need to address before going forward. I have left some remarks.

In addition to this, you need to add 'power': PowerScale to the _scale_mapping dictionary in scale.py so that PowerScale is registered.

@r3kste r3kste self-requested a review August 8, 2025 12:29
@r3kste
Copy link
Owner

r3kste commented Aug 8, 2025

The below example code is giving an error after your recent commits.

import matplotlib.pyplot as plt
import numpy as np

mosaic = [
    ["linear-log", "linear-power"],
    ["log-linear", "power-linear"],
]

fig, axs = plt.subplot_mosaic(mosaic, layout="constrained", figsize=(10, 6))

x = np.arange(0, 3 * np.pi, 0.1)
y = 2 * np.sin(x) + 3

for k, ax in axs.items():
    x_scale, y_scale = k.split("-")
    ax.set_xscale(x_scale)
    ax.set_yscale(y_scale)
    ax.set(xlabel=x_scale, ylabel=y_scale)
    ax.plot(x, y)

plt.show()

Error

AttributeError: 'PowerScale' object has no attribute 'subs'

@Sreekanth-M8
Copy link
Collaborator Author

Sreekanth-M8 commented Aug 17, 2025

In the current make_norm_from_scale implementation, value, vmin and vmax are transformed first, then normalized as:
(t_value - t_vmin) / (t_vmax - t_vmin)

Because of this, it’s not possible to implement a PowerTransform that yields the expected PowerNorm behavior:
((value - vmin) / (vmax - vmin))**n

there is no function f such that
(f(a) - f(b)) / (f(c) - f(b)) = ((a - b) / (c - b))**n => f(a) - f(b) = k * ((a - b)**n)

Given this, should we consider implementing a new version of make_norm_from_scale to support PowerNorm?

@Sreekanth-M8
Copy link
Collaborator Author

Sreekanth-M8 commented Aug 27, 2025

In the current implementation of make_norm_from_scale the value, vmin, and vmax are normalized after transformation as: f(value) - f(v_min) / (f(v_max) - f(v_min)).
However in case of PowerNorm, the expected behavior is normalizing before transformation as:
f((value - vmin) / (vmax - vmin)).

One possible solution, is to add an optional boolean argument to make_norm_from_scale that changes the order in which transformation and normalization is applied (for example, norm_before_trf).

The new signature would look like:

def make_norm_from_scale(scale_cls, base_norm_cls=None,*, init=None,
                         norm_before_trf=False):

In the ScaleNorm class:

def __call__(self, value, clip=None):
    ...
    if norm_before_trf:
        t_value = value - self.vmin
        t_value /= self.vmax - self.vmin
        t_value = self._trf.transform(t_value).reshape(np.shape(t_value))
        t_value = np.ma.masked_invalid(t_value, copy=False)
        return t_value[0] if is_scalar else t_value
    ...

@r3kste
Copy link
Owner

r3kste commented Aug 27, 2025

Quoting the first half of this comment

Yes, it is equivalent to ((a - vmin) / (vmax - vmin))**gamma, so a plain Normalize is the case of gamma=1. This seems reasonable to me; we are not dealing with logs. It is consistent with the description of Gamma correction: https://en.wikipedia.org/wiki/Gamma_correction. First the data are linearly scaled to the 0-1 range based on specified vmin and vmax, and second, that 0-1 range is mapped to a new 0-1 range by raising it to a power.

It seems that, doing normalization before transformation: ((a - vmin) / (vmax - vmin))**gamma is how PowerNorm is defined / expected to function.

If normalization is done after transformation (like other norms), the formula changes to (a**gamma - vmin**gamma)/(vmax**gamma - vmin**gamma).

Copy link
Owner

@r3kste r3kste left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some formatting issues which needs to be fixed.

@r3kste r3kste self-requested a review September 6, 2025 13:22
Copy link
Owner

@r3kste r3kste left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sreekanth-M8

  1. Some stubs have to be updated in the corresponding pyi files
  2. There are still some more formatting issues.
  3. There are disparities compared to other scales and transforms. Look into it more carefully.

@r3kste r3kste self-requested a review September 10, 2025 14:13
@Sreekanth-M8
Copy link
Collaborator Author

import matplotlib.pyplot as plt
import numpy as np
import matplotlib.colors as colors

X, Y = np.mgrid[0:3:complex(0, 100), 0:2:complex(0, 100)]
Z =  (1+np.sin(Y * 10)) * X**2

fig, ax = plt.subplots(2, 1)

pcm = ax[0].pcolormesh(X, Y, Z, cmap='PuBu_r', shading='nearest')
fig.colorbar(pcm, ax=ax[0], extend='max', label='linear scaling')

pcm = ax[1].pcolormesh(X, Y, Z, cmap='PuBu_r', shading='nearest',
                       norm=colors.PowerNorm(gamma=0.2, clip=True))
fig.colorbar(pcm, ax=ax[1], extend='max', label='PowerNorm')

plt.show()
import matplotlib.pyplot as plt
import numpy as np

x = np.arange(400)
y = np.linspace(0.002, 1, 400)

fig, axs = plt.subplots(1, 1, figsize=(8, 8), layout='constrained')


axs.plot(x, y-y.mean())
axs.set_yscale('power', gamma=0.2,clip=True)
axs.set_title('power')
axs.grid(True)

plt.show()

I used these two examples

Copy link
Owner

@r3kste r3kste left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sreekanth-M8 Apart from these review comments, there are also a couple more things left

  1. Add docstrings for the added/modified functions.
  2. The examples you provided in this comment are testing for PowerNorm and PowerScale. We also need a simple example for PowerTransform and InvertedPowerTransform.

@Sreekanth-M8
Copy link
Collaborator Author

import numpy as np
import matplotlib.pyplot as plt
from numpy.testing import assert_array_almost_equal
from matplotlib.scale import PowerTransform,InvertedPowerTransform

array = np.linspace(-0.5,1,200)
pt = PowerTransform(0.5)
ipt = InvertedPowerTransform(0.5)

transformed_array = pt.transform_non_affine(array)
new_array = ipt.transform_non_affine(transformed_array)
assert_array_almost_equal(array,new_array)

plt.plot(array,transformed_array)
plt.show()

Copy link
Owner

@r3kste r3kste left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants