From 92b074d9f8c41e623c4a74c972b72d2f4d70e6e1 Mon Sep 17 00:00:00 2001 From: Sreekanth-M8 Date: Mon, 5 Jan 2026 00:35:31 +0530 Subject: [PATCH] added PowerScale and PowerTransform --- lib/matplotlib/colors.py | 88 ++++++--------- lib/matplotlib/colors.pyi | 11 +- lib/matplotlib/scale.py | 106 ++++++++++++++++++ lib/matplotlib/scale.pyi | 23 ++++ .../test_scale/power_scale.png | Bin 0 -> 10104 bytes lib/matplotlib/tests/test_scale.py | 51 ++++++++- 6 files changed, 219 insertions(+), 60 deletions(-) create mode 100644 lib/matplotlib/tests/baseline_images/test_scale/power_scale.png 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 0000000000000000000000000000000000000000..bcc59442ac84e9387708d861d497211499c8ecc7 GIT binary patch literal 10104 zcmeHti9eM6_xEL&%2uJW3|T4?MIl=V*`uNmp(Oh*+f28LvW%!CTef7$n#5!&Yq=vL z+4p1{Cd*95%sgk@_x=6d-{*P$g6H*ky}Wo`*XKH)^Eu1=ywAC=`(`G3J301oAPBPa ztiFypf-qtbgkg-G6|N|D4NbuxOTRN#eiokB{Q|H0Ttken`gyr|`nkC{paQP>__}y{ z9G8`smz9%sx)va>sCYu!*~#&k@-Zg`B_~;@V{*q7<&PbgmqFd|^YijmIeOInKab0L z`ZymID-(VVTWs^vzvPP`TvzG83@0J{P5Ss zT^}WN@?=$_SGP@R8N>c4QAzgpCrbl0R@OolCLq8;J zO?Ccv@jZiF^KA&?lPy2XfFM?v7*U932RoQZ`2fO*OvNDVNc{2tfAK#CG4kzZa=KJ) z6Zii8d#l=De+2~vaW&2-^!*Xj|E&$xn}^%K{cJ zX9&e(ib4Zqq!^YZcW`p*qS5GXM@{_B9XpzSd`?Lep41jn4U$U_{q-@Y6&X3Om?6}j zpJ|?dK0#Ql@LptOc3z$oD=TYOVWIS_H!AQ!v%@Np*jnk@mxaTfn)OycUuG5KP(~^) zsq!4fS1|D=JnoE}c4AqUJYVHeQ&STfrTs^DUF=35n$joomhoo=Q^MnQSn;Sc))TX5 zwP}}WQ$s_;=6p}S#aS%#RO%j0%(eBa(ir4y86$h?pPYh9+>MQmDJ^n+lWksRwnmdEzwzdRI{7k<)=SO~*=SCwfm! znT3*X8=u88$z*<<^|2Akh$);*^uFeHtT@*6Gevo?WozCKtt)FKQjVkzza4fNG5N`# z{-9Eb9&@}1zb3+L?!2@8{w3g_UzoZsw#Vv zS`56e`5lwRerBzec1-m4cRQwQ@HLw1sd(~)j;WrZUuBg}ULl6mWJ z8xja({@1Pnw`nfk6Y`3?w43h>9DeeHni>^-Aos1D`Ge@*z8i;Nw{M$E9yi^htW_>& z4ojW)CDo&T=)hgDUf?Kr`Dwk8TGBdC49dV~Z3dbK${5q|qp~~Z2 zVvBu0=Ncr|vC|H_(zwgJPKgJs$hW!V>K0{HQNTT~V(n-agT$$vulxU?>^Uy zl`R9+^;Ivj6)#pP>N#eklEfLfkvSs>bi?dKLSDNg{7w?SJthdeJtoX#**dvc*f$yK zEp~YO+uY(WLjtwK?%uAJ)^1j|QP#Kt2g;ll{a^pIyWhVkl4p(xz3LMGa_s@&%U(q| zyt8a<^X&F)Ld)H*pvj3F?{3|a8F{B5oGs#8V(aSb=r-qqtxJFNbKV0RtG3wHH_Qj( z%gS(S!MK5}!rJ~Pu4%jo18Vy4V}o~LYr(04GA)HO(S*6`hor_Y`gCcZM9LHTzHq~% z!q(pcn%rGgvg$W#a@9_ZcJsjb@8Se^fq-kWu}>!MRuwlBZWj_J1{(?8{o`D-J4-Gx;L;WWoO9UA20T#rxtO;qgQ7lr6xqK)bFlW^HmO~{peNl zby*0b85vq=yA77A;AiI_;6f~0hih#KAFWS0Qs(}6ta?)tVQkdiAr+iCGh|Vwm=k2oL#Tpz6En zCaA2qT1xEKkCMzOx-Sg)82VLJi7OphgKRZ#JdVlNOVTwY)T1@HY4cT{ z&Hb)@Gqi9BrO@B_orG!J$tHfh4ZoVut8FG{M@V;AwjPn92T%R)=6M1-oEBiz@#uh< zgMX%So%d)9=^G`CkbLT!j<1E|2a5ztMpXR1bOs|e0cqn_G`WA4B+{97KR>xY?nxR& zjnJuqlMo&#lOMP|)T;N3SretG%Jj4-1izX~>WegK`aPkeqZ8^eKR`;N1zCp%%n#g= ziUyJ#PYEVOaRCONXI)l&VMVu~NQeN6H^EUD^zCx|AeREddA zWGwUx(#9W#xUVJLgxWQ9r~WtjP8vs@3=Y7`8GRg#Opdbs;?a#R|BdfPVJES1MYbM z7Fi8mT4q-|Hi_kL1#Ml4tCFhQWa3izFip3Z1uq>xp}dQSC(T_p+bMC6Ci1e&%4I#g zx8skzuy|uoxaVcC{l{6mas$wDU+m-SRT#@{7^g>&f{ITMSx#*9*dVW2jC29S%_8=b z4_odjLK3!~vk%2HdpTIl$+93iAjQu!srUx zyA`v(&}KvJg}l(*Ss;x`dxQBX2Tr;vcUg_aZTr(55E0K=Cs))kAV-#d!)%dZRnWfN z2w}dMs@L%FTCd)FWoE4>+b|6eR@jxw-9o$hY&^YP92e?U3 zUdGtZ?lj%EZ1HZOnf>j8>FFcJJI~$V+tSX8ZiR2NNX`{@P=~=3zWB$cWDB_+a28;Sljj@@FK#Z zS#FFlKd~LjKzx?R-v7ko)6r{0qLdnM^lFD12CRJobPKqJW^B97#O4qb6hu{&lZ#)~ zm+2;jay{RpS^gQ6UHC<1j{m2+TQO#*Gi#+mIY+j8LFK!$H?*tJ-R|c z>rM869Fys>w%(RYb2+WIQ^e>;5mes@(9u~Ktxz66JN5&tyDWc2%`PUXgHsPcQ`H9@ zzT})64wYM~uw?U+1)Uyk(=z{5igOyXXh+7iY*w4d54w(1KQ3orRe}p5ypf~tj?W9ySeDYzV`i7lx--d(c`Ubjx{#-g;H?-v~!k4pnK%Y0NUc8P2eiAk% zWhKG9jy>6x`SQXeT}i7-r8(dq64KjSUoztBI+R7mssNvE2r)8AnoORI;JHdp+7PE- z*jxa-2SRe9NVX|r@Ebsgj537vNOlc{{Il4&s|cQ)!tJy2fk=8{D|o26(=KI^I_ODG zQh_)%?0`fpcaF=5P40ab@C@QTOC}VumaImV4mNUIRiS=RJSp2iMUuB`bMB+ zXRHp?RMmGDM3J?24ixgIa;P6l?7C$cZqS-Ho%IP8Ac5!Hv-JDUCO@eMMBpeJQJCou}CGaEY^1Qz93^mA?h?ejEz1wRrn60$;4GD z)tyBEMVKKKpPL7DC0{sNn1;}+3dGzVa6r6uFDM-7{y=vaJr#wpYs#jHq({w|*u_Le zwWd)eoCO*4Pv@V1V4kl)^Dnb9^4f=Zv{-tOk9VBr1*6##WI9n$aoVV;b^niCs+e$d5;0~1H_1BDV)KhZ~- zS!PoP#EH(@OfLFONwJ(XO=@+#;A#;{sT%=v>OhcX9YjmIa;j-qkI=rv#ddWSu*8P$ zLT;a-AL3g8-y@C0ZH~FKB&uNr-8n%qQlvfXR=cM=ojzi(sEbS{9`_~>#C@-regg8E zphtL)7gABdQE;9_C@WtmY)}&X-N!x!ZT`SoW@U;_oEt`S^UyCAZ%ko3(GIpi3r&Oe z=>wi(YA(oHPUKhP>nR~c)YFf_x5FEsliLaFz4K)?Uj*%NdPI@)+c28O5JeKbi=&?S zh3NHfFCc7Jxslt?Anv4f4Zji^lREV7=+(au`FrUejUa-JO~$kPujVbAiwA?jxaMbg z6wG!(SfBAHTM|XyWdK~yb)+P9Ra{T_!o33#sBa@}Pq1*t#+!VA^^LREB4-erLnVj|N z3{m7i*H3#p91bV_awCpXhbeTau+jD`8dioxjh@L;u#E%K=-B!L4>s-TW@49#qH2_D z5=CTU=xTo0&#yQ6Gox77!ckjwE@=-*pRPpaWy-H+(_-vS+Zx(fHUO^Eh*34KsmQ7Q zJT)|XQON&eOVxcxn}MXQ`n_(lRIe0;9(0XabL_w@?Z9@3fI$ket)uRs!aOws3#Sx0 zOA~Q9sj9w5&*XDB8b6v%=Hk;rnAH=g8r}Giqns^Me#&^ft0)$Cq1Qce%Q z`iAUD6COsJZHG88+L)e=_V&)%P48fm@ZA))O7JgN4h{Rd(1R9ErW$t*EcmciF7ILr zMN!Z>{k>%$NgwmK^Ne?SmI^SUmT!Y1*E`b%Vm>DOxGl{;2%+n=3TM)XGX;?D;WeHI zTtpb!9$9wsPv%xMc6Ik~V~3WKc3bVBZ?C+$xiB|siLX=qR&>)iXkVKoT>bOuvqPclbVb703efnTuwz-PX zufMKQz5F1f&U}$N4?*7sg*u2I4$%8Q)oHSLq?az}1>4`ZGU@riiEc0kf}=T?CMXzZ z`A?PVhbk3+3%s8nc%zuSR7f1!rzQmMzYb`TGXVZc&xaCMM%iu%qyp6&zCLFL{quvJ zh1&^ZY4hNr~+}W>Ysg_8pT6Rpg_!1}pB1X^9S51hN< z5#xymI50d5nmjHjM2A!nb%Aq36om1>!ZZj2Ro_rKF*n)=A8tf+rl&q$S3}uf^j?W+ za|`|r=^cplVMI-(Zt!>iT<*FZ6&2*x_u|G)H(;(WHU7SF{iPBQniknlFA(6R>F2Dh zJ|>jyE-PvL9lF)LWzOwK%KXEeyC*em%fXsAe^MhJ(_waBD_e2%Jil)Ct6X_{gEQ;> zoQ}9Ty8dfR^F)uBUU$f$YIaP##hjgf3N%DP>vuq(ZLsYl(C0^6ex^Y3(>PJNQw?a!*i zxM%1Yr}Sy$PU<2>uBXAqDT=6$i4W)V&NoV{-ms4lN<2zI8aBdU>Kb-TqF5crI~_g zXn24tgS^F40fM1gib~OUsXyf_F;a6(xbSky#5{3L5=LwAv~niwl3z?!gNDfOwF;J9 z+8C5IqiJ1^dHiCMeDng2=0?-v-5g+{uY64!Z=Z1t^^ts2v>2sk?Qu3(uuTHADtg&S zCkYU!_gII~@pQZFS@||iAbl1o(b3j!as+mF+MHSQrLPGsLDEzxMQxMt+q5VeJOeQM zTLRHa^W8{#eK1LSg~5v^T!@Z`iO5{)*ZQ1h&kmcos*)?%-n&0Y18Oxi)L9b-?XA_o z6T^IGT5fo{8$iyU1T|!EUEO}0%egB&JY}8PqbP64!FD{`(e&tfi{oYfl!EX6)}|0; z_-z=oyi}@$F((`^0Dx728_6_LrWjs+cgsbFK47@v78SI>kcSrC2})al(tN#*B6i>3 zs0ZT)fyPC7aR|&k0rlSh89R(l-(<+Y%EFDgwgP@>uKuUuI4bVz24uGHKVO9w8R_SD zKLXkx0eQ#i@|y4Gle*fssyBqY0uPeep#%zS4&V1Z_e{3+&CawkU*Ev)N#rExwslMJ z8-*=@dbeE9-aPc#h?BB$EoD{3XSS*hna8{p$fjX&FljP6!$xF(4It|Pz_?64;6Zx& zJasUS%w>KSxuY^Rb=2io;a7ggmsStbuy#%a_8+2=V0^ZTc%vM_iloE$N>1$+!;r@J9} zZoLGrp$|0n*q`HE-BYVy^iu`i@_mloc`-+Hni;ypNDckOxw+Ad;Y!hO{(N9j(m_U) z)6+(goaU{%Gww$3UZA#hcZhOtEswTKuM^EIx8Y*)k@nlP^2>EK$VizC}a)>3<@in*0r>dWz#Nn7e>f4OvE?A5G#Q^f}R* zZy@bOInj`4^acqgm@tncP1w!@?7RfFzIS4o&Bf>E@BT&+toj%JBgV2)@e<(}lZ41)#jdBD;Lk@`^b?_O!u4t`%} z|2~2(8;1*__KucpV5XH!||g#T7Vr9iNvO~{Sd)0YKV6L zlkz<_kl++b8(#AwF_m}n$>qd@{weetCUQ?VyRuqdn7+FdqQy}SrcqwqTo}jJ4u)jo zY9+u^DF81Vhr%U|C0|^h=-(B=Grr8$vz_?YYK7mk()=*=KEE1J**vq z!3NoLPWhRAvYp)+?Y&K2t{^!pgX z95Wd##_P@Xm!w2!Un0}{Kz19*rW-$dlBsVZHrlc4Tekq$;`gWY)x3x(3kcc=L>)Na zTFA+kxf8&=Oqn(H8NiDtc3B)J)NXZuX6PX%_CsT!%>P;9tQ;;PqNnkc1Fs4qHH1ei z5A6f!9fl%6zZOd$Q_sL4m4^pANWEJlj%fWs-%>4X!HwRp)HxcvUBVrn^~PL7AzF+D z*7ZC*qv7@zey?kUlKfttKkOZZUtJ0>M4MHCR+v|I-E+WrTapbOySfH;+tr&AMS7$e zxOwg8lU}QYY7_08-+xl*k=7WFM=FozbkW~C3`ft1i8;`O2kXr`i;xc-=47pBlz(+u ztBdq5%Sx9KRfK|5Ux)mLt`hv~Z-U&}GX-a!`*#JLOF`JBH5a$LSVHeQ^sN$#=mEO= zk|4Tt&F~paID_WCLrrfKiQmqMlF6R?Gz!Cj{tb!B@W3Aio3p=y{P>dE4JGJG}-9UtC*m)h4?8g)mOt*`sNt%NZ6Q@IPG_INyUEQ`+RQuu;gTZhb-Y z74#0j1)#F&+9!FQKYPDN%?j6RNomp(~u8e5$TT{s3CD_w9~n2oBMe`OrRBZ zU*r1K^7qv)Pil~I#}%A#;r7pEfjHP;zci*)|01=m{3dbqWZ?Q4YeNgVn9m$2nS{q0 zV-0T3z9_G5D!oSO6`g=|Ig@u||Qea4CD~3BP`!Z7FL^4!Ga%X*F zjE$^+AE@9Q^&)8#MWzxFc9F^2_azmt`ag*z`~T>++N6Fi?E214_|EhOEn32?N#YjS zv#B}|TU2ndUiGbe7=7xhx-)*aW!b(*2Dt|x8?4yzM>jL)UCloqF#?`xE-|rtMzdJX z9)a;Np+|lgjX|suBiOiF5>3^UR|RAq5{s89eY6l2yj9grs_A=d@PC)xNj}!Lspzwmda)v40*#`J3Qg{d%W2fHuX4Bs?6xI4@2 zptu@q^y~PLje@-3G<@y|yt;ZIQX8rcWP52nT6!uC(YGzUR|oJi6P@C|n4mX`CfGcB z(O%}R?Y!`Phz`;pY$z`cmuweL!P~8MkEPfHQjyFWKC@fw_k&@IY`8-gM8mlYIGG*vAHhA`Q)#H;u^=0-;d$`$^ri9{IURmSDrm)~Fi z^f)M37Qq9oz!?0a0@fu0yZ*1j{%fnS$HQzcc}m{BRIV3a|4(6;Yk!Ekr~NN-tf3A_}=?Dn1(?`77odN;SeR5x5h!vy-W zNc5`KGV#s)PjNSCVp^IsFgQHB5|LtIOCfg;?{~1xbAHOS^y7g+!B4n)Qq3YR&rXTn z`(n5xkpzy7P5ar+U#rb;p{1!TygX?bI8S97F-64Bao%d^PFbSj&=#*1ZcUw94lgbh z*IJI4roRCU_nFhu`7@o$LP8b4E_!p$xypXFdwLZlHM>CG-!hAGzK3v1DA#xYu zYWB~|W*NR+sge}8l@Ff^AY?ubSGD{#EzL=uy!!8fm6{DDBIfTG62e1oWMNh_F`gDi zSY$h~4P6@Z;`%4TFk~daUxOaV|L-}LdBORJ(F!Tn$xlD0Lho0Mv9&MFlw65wcT6lR zuHd%YoZ84~Wian}T+IqEe>qsN=Yx+*bi~trlFhYxrUI`q=E#nbc_yo&9|^BdN&SUe z^zyAD3n{3$yT@ob!tXbp@cyZ+rNhh*JZb7xA#js6#L|_IeyhNP7W|8?)U0?QROUTb z_dNYI%BO0F*C!NP8c&e{liS!WJ=LG6Yv`8%bF-`(-D+wvcfI$_XkW0T(hr+{D(up7 z#G_=OgvVQ*>74^(rI+GN*a&XgdU1uRd-`uy@wT#%n|31wuVFvVtoZ}N#qCxe+0z$7 zUmT!8f)T}eOOw@BAUW!vF87TVS5Hf?S}U_gTe0&MftY_cxi4c2Lp#oEkAizAfD(Ma_8&5T|Ddm^?y6ke`Qqm496sAHSEIEvB$l8YS*Sdear#^E+XY&^qcm%stPT4M#jy(0Q*+gm7 zt$gS|XeqYtRxR*)Z+gK?hM->` zrFNx3%7&__dmR~htHkGiB$g0MJ^|fbyf?4*PWs0MrmBRd&Aw&_`3*AJa;p9a>2#gY zvw13cqjtZeKR99Rhd!s`!QC9^4(`6VT7QVd@Hf$@2VBHRpibwciaC${tCjBGYNcN? zCFmF&@?kC5q8YWyKrQTAruTT|1XMB(kRkko*>7&JknbS=;`p+rvm!ROh5kBZPT@r za4C9@E%fs)5%J2_?yUbxsVW$o+-Iete(oYhR^$1_J}Kvkr7NoZJi-)K8MRqQmbdj> zFMF{j>CwEJ4Y~iagqYY$ zgb!sLoSeirHa4=#CQqMvu%n%we+}Hb+oZkluJnHZ4@xK6qqT7rB literal 0 HcmV?d00001 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)