From 596e83d6d27be9bcf6bc9e3535ba369879dea9dd Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 14 Jul 2021 02:15:46 -0400 Subject: [PATCH 1/4] Add colour vision deficiency simulation filters. This is based on #3279 in essence, but rewritten to use colorspacious and modernized somewhat. --- lib/matplotlib/artist.py | 17 +++++++---- lib/matplotlib/colors.py | 64 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py index c6a371c684bf..ce1654dbdf2b 100644 --- a/lib/matplotlib/artist.py +++ b/lib/matplotlib/artist.py @@ -924,13 +924,20 @@ def set_agg_filter(self, filter_func): Parameters ---------- - filter_func : callable - A filter function, which takes a (m, n, 3) float array and a dpi - value, and returns a (m, n, 3) array. + filter_func : callable or str or None + A filter function, or the name of a builtin one. - .. ACCEPTS: a filter function, which takes a (m, n, 3) float array - and a dpi value, and returns a (m, n, 3) array + If passed a callable, then it should have the signature: + + def filter_func(np.ndarray[(M, N, 3), float]) -> \ + np.ndarray[(M, N, 3), float] + + If passed a string, it should be one of the names accepted by + `matplotlib.colors.get_color_filter`. """ + if isinstance(filter_func, str): + from . import colors + filter_func = colors.get_color_filter(filter_func) self._agg_filter = filter_func self.stale = True diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 8225a8bcf399..d65cbe37e2b2 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -2394,3 +2394,67 @@ def from_levels_and_colors(levels, colors, extend='neither'): norm = BoundaryNorm(levels, ncolors=n_data_colors) return cmap, norm + + +def get_color_filter(name): + """ + Given a color filter name, create a color filter function. + + Parameters + ---------- + name : str + The color filter name, one of the following: + + - ``'greyscale'``: Convert the input to luminosity. + - ``'deuteranopia'``: Simulate the most common form of red-green + colorblindness. + - ``'protanopia'``: Simulate a rarer form of red-green colorblindness. + - ``'tritanopia'``: Simulate the rare form of blue-yellow + colorblindness. + + Color conversions use `colorspacious`_. + + Returns + ------- + callable + A color filter function that has the form: + + def filter(input: np.ndarray[M, N, D])-> np.ndarray[M, N, D] + + where (M, N) are the image dimentions, and D is the color depth (3 for + RGB, 4 for RGBA). Alpha is passed through unchanged and otherwise + ignored. + """ + from colorspacious import cspace_converter + + filter = _api.check_getitem({ + 'greyscale': 'greyscale', + 'deuteranopia': 'deuteranomaly', + 'protanopia': 'protanomaly', + 'tritanopia': 'tritanomaly', + }, name=name) + + if filter == 'greyscale': + rgb_to_jch = cspace_converter('sRGB1', 'JCh') + jch_to_rgb = cspace_converter('JCh', 'sRGB1') + + def filter(im): + greyscale_JCh = rgb_to_jch(im) + greyscale_JCh[..., 1] = 0 + im = jch_to_rgb(greyscale_JCh) + return im + else: + cvd_space = {'name': 'sRGB1+CVD', 'cvd_type': filter, + 'severity': 100} + filter = cspace_converter(cvd_space, "sRGB1") + + def filter_func(im, dpi): + alpha = None + if im.shape[-1] == 4: + im, alpha = im[..., :3], im[..., 3] + im = filter(im) + if alpha is not None: + im = np.dstack((im, alpha)) + return np.clip(im, 0, 1), 0, 0 + + return filter_func From 04f1cdc822cb4fceac1e99fa2dfb197a84b80e89 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 14 Jul 2021 02:17:55 -0400 Subject: [PATCH 2/4] Add a Qt control for CVD simulations. --- lib/matplotlib/backends/backend_qt5.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lib/matplotlib/backends/backend_qt5.py b/lib/matplotlib/backends/backend_qt5.py index 0523d4d8ca7d..6e315bf75c53 100644 --- a/lib/matplotlib/backends/backend_qt5.py +++ b/lib/matplotlib/backends/backend_qt5.py @@ -621,6 +621,31 @@ def __init__(self, canvas, parent, coordinates=True): if tooltip_text is not None: a.setToolTip(tooltip_text) + menu = QtWidgets.QMenu() + group = QtWidgets.QActionGroup(menu) + + @group.triggered.connect + def set_filter(action): + filter = action.text().lower() + if filter == 'none': + filter = None + self.canvas.figure.set_agg_filter(filter) + self.canvas.draw_idle() + + for filt in ['None', 'Greyscale', 'Deuteranopia', 'Protanopia', + 'Tritanopia']: + a = menu.addAction(filt) + a.setCheckable(True) + a.setActionGroup(group) + a.setChecked(filt == 'None') + + self.addSeparator() + tb = QtWidgets.QToolButton() + tb.setText('Filter') + tb.setPopupMode(QtWidgets.QToolButton.InstantPopup) + tb.setMenu(menu) + self.addWidget(tb) + # Add the (x, y) location widget at the right side of the toolbar # The stretch factor is 1 which means any resizing of the toolbar # will resize this label instead of the buttons. From 7e380d92fafbf38129cf6142ad95f8edaffeb7db Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 14 Jul 2021 20:02:22 -0400 Subject: [PATCH 3/4] Add an icon for the CVD simulation button. --- lib/matplotlib/backends/backend_qt5.py | 2 + lib/matplotlib/mpl-data/images/eye.pdf | Bin 0 -> 3526 bytes lib/matplotlib/mpl-data/images/eye.png | Bin 0 -> 635 bytes lib/matplotlib/mpl-data/images/eye.svg | 70 +++++++++++++++++++ lib/matplotlib/mpl-data/images/eye_large.png | Bin 0 -> 1149 bytes tools/make_icons.py | 1 + 6 files changed, 73 insertions(+) create mode 100644 lib/matplotlib/mpl-data/images/eye.pdf create mode 100644 lib/matplotlib/mpl-data/images/eye.png create mode 100644 lib/matplotlib/mpl-data/images/eye.svg create mode 100644 lib/matplotlib/mpl-data/images/eye_large.png diff --git a/lib/matplotlib/backends/backend_qt5.py b/lib/matplotlib/backends/backend_qt5.py index 6e315bf75c53..67908406ecae 100644 --- a/lib/matplotlib/backends/backend_qt5.py +++ b/lib/matplotlib/backends/backend_qt5.py @@ -641,7 +641,9 @@ def set_filter(action): self.addSeparator() tb = QtWidgets.QToolButton() + tb.setIcon(self._icon('eye.png')) tb.setText('Filter') + tb.setToolTip('Simulate color vision deficiencies') tb.setPopupMode(QtWidgets.QToolButton.InstantPopup) tb.setMenu(menu) self.addWidget(tb) diff --git a/lib/matplotlib/mpl-data/images/eye.pdf b/lib/matplotlib/mpl-data/images/eye.pdf new file mode 100644 index 0000000000000000000000000000000000000000..52f18e8342f88713254957adedbec8b76267fb3d GIT binary patch literal 3526 zcmeHKdu&r>6jx#tE@6TiBva%=GRMRAKHA>4Fc-RZ#VK=RFPH=8UHi4Y(B4~a?^p*u zCq`!wWK$j?8e!4!N>BqRNP^;PAjo*QD4PoWF-VvYjL0jf=evE~trdZ2;vaU8w&$LE z9=~(W?>jfA)a5p?Ml+SOu^VmJPO*qVa@}kyFAveBf*(f^2a+;GS2Tt(qMd>&1Z6+U z&!@2DC5krP4TVv4O1Ubi7@1=?xn)U3rr76FK~=FLAq)9R(WQ#)DaR_R0Y5G`*vC;7 z(M2KfSrC7B#@~z3EJT+F1g|Lh5g)sKMoHal>`{?bGoA{E)hFPPBw39hmWf5+glMn? z0(A=P(Uq~I7OG?h+MCD011+65(xs14hCW}fty&SK8M<+Vk?%d0)JzIOQWzwEI zR_DJqlRmbjAUY=7n|tx}Obi#-oH{DLJ|%nUGRBd;X`l4QJMS%iW%zpTyWoK*PIep| zIC9QhVcxXP7pp2R9$UFTukov4J1_Q*%6t3Qz}nH5Zra?-FCul0hZ?WAmIBF@<(Whm zXb=-w4Vsmdk=i6MDTv{0V3C0}n*j!!71=B{0{7M6;&>ZHm)F;+TKBnSC6wsIL<8uN zd}x|uapCBJT??qiXh746#@${%^2g7cFZ^CRB71P?z}(JZHRGRc+v_@nPb{rywpYL9 z7(TgyAJV?ce^NQyyt4H;s@k@2_=+vdqs7(i7lx0Iu-vCzL!B>e-!kZwX=`QMQ!X^Qj5PctKY{0|Lfi{yu^{>}fkdxMMb|Tgw@ispV>wS3y=nk*j1iNLJ4gTt zh#V0QApz2(z9cGA3-MWnvzY669^9YRP;C`e-IPIiiM~a z9dH%{zRFb`sv=Hf>`TJe0Y=YAMx{%FWEGo43z+x^{-_(Z!CBJ$->|I=G5l}q`s(|y z;HC9xhT>dP9psHnVGUFiwYh zRKY$9Cdy917{*_Sw^&RTgr*#yW7NeLNewE-yoWELJ!?k}@L@AQ-LcIdZ$GC(k0 z22u3WV;DYNX5!LfVJx;Z9}LH2(BsqeShEc_`M&Y7oINc!U=P?z`s#5OGi)h+Wem@x z`QU73T`a01h(WAq4_rl(PexkD!DB|5ER#mn9y!Xz`4|dCiWgA@Mb%1YVt6Z+lT%pY Grv3!`lDCHd literal 0 HcmV?d00001 diff --git a/lib/matplotlib/mpl-data/images/eye.png b/lib/matplotlib/mpl-data/images/eye.png new file mode 100644 index 0000000000000000000000000000000000000000..365f6fbcde5d7cf2da6401167bb664cfe2a09171 GIT binary patch literal 635 zcmV->0)+jEP)V>bE;KGOE^u#ibTKnEH!EjmF=1w7Wj8ltHZEi` zFfuVPH!(CUAZT=SaC15@FKuCTaBOdMY-wUHZ*pfZFd7L}00009a7bBm000BA000BA z0ri9JNdN!=$w@>(RCt{2*3XNLQ5XmC&wJA_nMj$GUyEr5OYDdRG9_iL`~jnsjg5_k z>=jFii9$BW$^sjNq-2<4{9LFzX2TuHVjPS2x#M)_z4zM8LYzAFp6@y5`+d&ytG7rg zna@$1SN8t`|AxSVZU>5@7{p!-#q7sByv9phODQ$l5~P&oOcdCG8~BOTVSd3$3{-Me zkoVzI1<_e-#+EihKQMx#tpL_zEG|srV|*Wv{WUmRLH<1Z9!2jQ0j$Pk9C{MgPU8a> z;s|EiSbsg@RFCyd)Mf>O;9+cDMh~{e`XW|!AWx_VPd{D+b}Sb-72qQ*3T!yAD>&RG z(BGQA#{fP@a_nphOyUz(W-eRNg*2b{TVlCA@H5RqyNg~diM5ncU76>9GVZjjhiCCn zT!y!p!1BzJZMxUN8!XQ5><|1Liu_p{@g2{wF2fH7UPV5EO<8e{DC^;=NY&Ol*o7%H zaKP`tZNPbDhJ3|6oM>8(rMM8H59G0|sN}VH9EZo^1$}K=%8)Vx8*%tS%1W zRa~4=Hn`iks!Zpp%vHCryNavng33`8MGbqfFRUF@7SnrWHyy`lN@=_;$6x + + + + + + + 2021-07-14T19:48:07.525592 + image/svg+xml + + + Matplotlib v3.4.2.post1357+gf1afce77c6.d20210714, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/matplotlib/mpl-data/images/eye_large.png b/lib/matplotlib/mpl-data/images/eye_large.png new file mode 100644 index 0000000000000000000000000000000000000000..f8a2911032a4b41a4d71b5763c40c7ec9a03ec13 GIT binary patch literal 1149 zcmV-@1cLjCP)V>bE;KGOE^u#ibTKnEH!EjmF=1w7Wj8ltHZEi` zFfuVPH!(CUAZT=SaC15@FKuCTaBOdMY-wUHZ*pfZFd7L}00009a7bBm000MK000MK z0XTY@+W-Iq%Sl8*RCt{2*hy$zRTu~G-%XQhoN8@aB9)>G5i8Yz&|na&C{9$fu(}fk z5pk{yt0*EW(uE){oEEKU#Ug^#O%+r`MYMxOi{h%S+G@lhNo{P4sJSlAedNBId+&RB zH{}s{AAFm0zTy8r!*_-#j$?CKy4nq(s&ojnTRH^VEgb^wmJWe-ONT(ar9+_IGUo|& zR}BzFQG|1`GSLgL2&Z8oj$jmH*o*Dhfn9MNAFY~S9LFsw&coCA9&ro$7hcEJ=x!ND z3&2<5;}$^|=>gn{GUV1DA)iy=IKubKe76W?Xp#xdM70bGb*GC|+OQf$t&Z>UW> zR_FLN(h2n85gbjmPvZU=6A&uH;Z&f%ack1opIJ@^aAMMTV++frruQZ>286wk?t4zb zzQ_YVB-HMj^UoMe^{>eUe=uq9!ygq0Y)R$l$F@xWlMM+7wS6q*^b^j?*sRM0UY@ky zQ~`X9-I+YSrkCrx3JKt3>`58_igR*i=;ch%+VXj(B7upVT&JinB9pkJh``2_;SmfJ z?2qR&iEqc*!d|FCV7wsT66{Oa{fGs11aN&O*li`%>CuWni}c%)oP+8M$7@*v@1;85 zP;wIDx?@CObAvPKp_J{vwOOcLrw2{Rc$|0?$cMrbn{9cgLI2yr9)NkmIh8vDF|KMb z8XL^+=>LY3O7eB%3pq1TGZ@YU96^6cz%$gJm{oOJdCcT{Dr3JXuL`}AG5WH>sN6G4 z;6Fi6NuHZC_WN;a(K)a$W4yH}I3m1M5&T{(E&+d~`sy=@OLNbG#9@VS3rr_@S8`b8 zB~G=Pc8;)9i8_FV1oN9DNN&YIl>s58`1ZuP@wz|1y5-b8QO(T&w;< z7YmoV%S)2Z6Ar{z6Whs@?;(7EyRoPw#sX~0A^oCeZ=GWr)%&-4)}FN;7bW4fzQO(IYCE-*H&*Ma7}<85-L$P$04j~=-)(w`NBrcdlMfN zD$yXiW&pY$YZX5?-^a3MV%4634JC@A0pVq|s=(w=;k4W??153CqMaaoaxE5qHZBrw zuX!?p$FVVP7FY{YewpxPQlWj2;#~2UbgdGg{H6mr#*J zQPhRyrv66H7v9GUP5q6I3Cn4Z@GrUpJL5R6e5 Date: Wed, 14 Jul 2021 22:32:57 -0400 Subject: [PATCH 4/4] Add a GTK3 control for CVD simulations. --- lib/matplotlib/backends/backend_gtk3.py | 66 +++++++++++++++++++ .../mpl-data/images/eye-symbolic.svg | 1 + 2 files changed, 67 insertions(+) create mode 120000 lib/matplotlib/mpl-data/images/eye-symbolic.svg diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 060dca2c2aca..69337d30aeb3 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -503,6 +503,72 @@ def __init__(self, canvas, window): 'clicked', getattr(self, callback)) tbutton.set_tooltip_text(tooltip_text) + # Add the CVD simulation button. The type of menu is progressively + # downgraded depending on GTK version. + toolitem = Gtk.SeparatorToolItem() + self.insert(toolitem, -1) + + filters = ['None', 'Greyscale', 'Deuteranopia', 'Protanopia', + 'Tritanopia'] + image = Gtk.Image.new_from_gicon( + Gio.Icon.new_for_string( + str(cbook._get_data_path('images', 'eye-symbolic.svg'))), + Gtk.IconSize.LARGE_TOOLBAR) + + if Gtk.check_version(3, 6, 0) is None: + group = Gio.SimpleActionGroup.new() + action = Gio.SimpleAction.new_stateful('cvdsim', + GLib.VariantType('s'), + GLib.Variant('s', 'none')) + group.add_action(action) + + @functools.partial(action.connect, 'activate') + def set_filter(action, parameter): + filter = parameter.get_string().lower() + if filter == 'none': + filter = None + + self.canvas.figure.set_agg_filter(filter) + self.canvas.draw_idle() + action.set_state(parameter) + + menu = Gio.Menu() + for filt in filters: + menu.append(filt, f'local.cvdsim::{filt.lower()}') + + button = Gtk.MenuButton.new() + button.add(image) + button.insert_action_group('local', group) + button.set_menu_model(menu) + button.get_style_context().add_class('flat') + + item = Gtk.ToolItem() + item.add(button) + self.insert(item, -1) + else: + def set_filter(item): + filter = item.get_label().lower() + if filter == 'none': + filter = None + + self.canvas.figure.set_agg_filter(filter) + self.canvas.draw_idle() + + menu = Gtk.Menu() + group = [] + for filt in filters: + item = Gtk.RadioMenuItem.new_with_label(group, filt) + item.set_active(filt == 'None') + item.connect('activate', set_filter) + group.append(item) + menu.append(item) + menu.show_all() + + tbutton = Gtk.MenuToolButton.new(image, "Filter") + tbutton.set_menu(menu) + self.insert(tbutton, -1) + + # Fill the space in between to ensure message is at end. toolitem = Gtk.SeparatorToolItem() self.insert(toolitem, -1) toolitem.set_draw(False) diff --git a/lib/matplotlib/mpl-data/images/eye-symbolic.svg b/lib/matplotlib/mpl-data/images/eye-symbolic.svg new file mode 120000 index 000000000000..ac53b1f81e8d --- /dev/null +++ b/lib/matplotlib/mpl-data/images/eye-symbolic.svg @@ -0,0 +1 @@ +eye.svg \ No newline at end of file