mp-007 / kivy_matplotlib_widget

A fast matplotlib rendering for Kivy based on Kivy_matplotlib project and kivy scatter. Matplotlib used 'agg' backend
MIT License
33 stars 6 forks source link

AttributeError: 'NoneType' object has no attribute 'figure' #12

Closed abedhammoud closed 1 year ago

abedhammoud commented 1 year ago

I have recently started using this excellent addition to kivy. My use case is basically that I want to have different matplotlib report graphs displayed inside a kivy screen.

From the examples that I see on this page, I have the following in my kv file (snippet):

                        AccordionItem:
                            id: _dashboard
                            title: 'Dashboard'
                            on_collapse:
                                root.activeview = _dashfigure
                                root.oncollapse(*args)
                            BoxLayout:
                                orientation: 'vertical'
                                padding: dp(10), dp(10)
                                MatplotFigure:
                                    id: _dashfigure
                                    fast_draw: False
                                GridLayout:
                                    id: _dashmetrics
                                    cols: 8
                                    size_hint: (1, None)
                                    padding: [dp(20), dp(10), 0, dp(10)]
                                    height: self.minimum_height

``
Where _dashfigure is the display area for my matplotlib composite figure, and _dashmetrics is where I have other kivy widgets (mostly labels) that display some metrics.

In the py file, I have (snippets):
```python
                elif instance.title == 'Dashboard':

                        # using kivy_matplot_widget
                        self.ids._dashfigure.figure = self.graph()

Where self.graph() is a method that returns a composite matplotlib figure:

def graph(self, text,  *args):
       fig = plt.figure(
            dpi=100,
            figsize=(18, 11),
        )
        fig.suptitle(
            f'{text}', fontsize=8
        )
        mosaic = [
            ['ORDER', 'ORDER', 'BARPLOT', 'ABAR', 'AAA'],
            ['SALE', 'SALE', 'PIE', 'CBAR', 'CBAR'],
        ]
        axs = fig.subplot_mosaic(mosaic)

# create dataframes to graph

df00.plot.bar( ax=axs['ABAR'], stacked=True, color=colors, fontsize='small')
df01.plot(ax=axs['ORDER'], color=colors)
...

return fig

The figure displayed correctly; however, when I click on the figure, the application crashes with the following error:

 File "kivy\_event.pyx", line 731, in kivy._event.EventDispatcher.dispatch
   File "C:\Users\abed\miniconda3\envs\alant\lib\site-packages\kivy_matplotlib_widget\uix\graph_widget.py", line 480, in on_touch_up
     ax.figure.canvas.draw_idle()
 AttributeError: 'NoneType' object has no attribute 'figure'

Most likely the error is due to my misunderstanding of the package API. I would appreciate any pointers.

Please also note, that other parts of my application would like to draw on the same screen. I.e., I have other methods similar to

self.graph(...)

that needs to clear the figure and draw other contents. I have not figured out how to do this yet.

I would be happy to share a summary at the end to help others start using this much-needed and excellent package.

mp-007 commented 1 year ago

I think your a just missing this part because figure.axes is not set.

                elif instance.title == 'Dashboard':

                        # using kivy_matplot_widget
                        myfig = self.graph()
                        self.ids._dashfigure.figure = myfig 
                        self.ids._dashfigure.axes= myfig.axes[0]
abedhammoud commented 1 year ago

You are correct; this stopped the crash. Now when I click and drag the mouse, the graph in my first image moves around (which is ok, but I must not be getting the best utility out of it). Anyway, the crash is resolved. Thanks for your help. By the way, if I could help document the API for future users, I would be delighted to do so while learning. Is there a place to browse the API of the package? thanks again for your excellent contribution to kivy.

mp-007 commented 1 year ago

to update the graph (without creating a new graph), you can to something like this:

    def update_graph(self,newxdata,newydate):
        ax = self.ids._dashfigure.axes
        #if you several lines in graph, you should store your lines somewhere to update the line you want with setdata
        self.ids._dashfigure.ax.lines[0].set_data(newxdata,newydata)
        self.ids._dashfigure.figure.canvas.draw_idle()
        self.ids._dashfigure.figure.canvas.flush_events() 

This is an example for updating a line, but you can use this concept to update bar,scatter,....

Make sure you are doing your update in the main thread.

abedhammoud commented 1 year ago

For me, the figures are very different from each other, and what I really want is to clear the screen completely and draw a new matplotlib composite figure (which contains many unrelated plots and bars, and axis). how do you recommend doing so? Thanks

mp-007 commented 1 year ago

for your visual issue, you have to set fast draw in python and not in kv. It's not a kivy property. So you have to do this:

                elif instance.title == 'Dashboard':

                        # using kivy_matplot_widget
                        myfig = self.graph()
                        self.ids._dashfigure.figure = myfig 
                        self.ids._dashfigure.axes= myfig.axes[0]
                        self.ids._dashfigure.fast_draw= False
mp-007 commented 1 year ago

If you want to create a new figure, you can do something like this:

    def new_graph(self):
        #create a newgraph function like graph_update but with a new matplotlib figure
        mynewfig = self.newgraph()
        self.ids._dashfigure.figure = mynewfig 
        self.ids._dashfigure.axes= mynewfig .axes[0]
        self.ids._dashfigure.fast_draw= False
        self.ids._dashfigure.figure.canvas.draw_idle()
        self.ids._dashfigure.figure.canvas.flush_events() 

maybe the draw part is not necessary (need to test):

        self.ids._dashfigure.figure.canvas.draw_idle()
        self.ids._dashfigure.figure.canvas.flush_events()
abedhammoud commented 1 year ago

Got it, thanks. This is really great - and in time for having fun with it over the weekend. I will share with you, my experience. BTW, what is the purpose of setting the axes[0] especially if I have many axes in the figure and any callback i would like to be for the whole figure and not one axis (like print, save, or change the background, etc)?

mp-007 commented 1 year ago

you can access to all your axis with figure.axes method. it return all axis in graph. So if you want to change something in multiaxis, you can used this method.

I have create an other axes attribute into the widget to handle the fast draw part. I know it can be confusing but I think I will change this part in the futur.

In the futur I will maybe include more stuff in fast draw. Maybe I will only used get_children method to get all the artist in all axis. For pan and zoom, I have already done something to handle twinx matplotlib method, so maybe I will start with 2 axis first in fast_draw.

abedhammoud commented 1 year ago

Thanks for your patience with me. So, I can register callbacks for each axis (to print/save each plot, for example?). And what about if I want a callback for the whole figure? so when you click on the screen, the callback would be handled not by any particular axis but by the figure itself. I am sorry if I am confused. I believe I will get better when I play with it further over the weekend.

mp-007 commented 1 year ago

to save the figure, I really like the kivy function export_to_png (https://kivy.org/doc/stable/api-kivy.uix.widget.html).

To handle the whole figure, maybe it's better to used the matplotlib 'pan'. It's slower but it's more general. You have to used NavigationToolbar2 for this (https://matplotlib.org/stable/api/backend_bases_api.html). I don't have a lot of time to make a example with this but I think it can be a Nice to have example. This project did something similar https://github.com/jeysonmc/kivy_matplotlib/blob/master/kivy_matplotlib.py#L183.

abedhammoud commented 1 year ago

Thanks a million, you already helped me a lot. I will try the above and see how far I can get. Thanks

abedhammoud commented 1 year ago

thanks @mp-007, I am using the following patern in the different methods that draws a figure into self.ids._dashfigure.figure:

    def cat_contrib_graph(self, *args):
        # if self.ids._dashfigure.figure:
        #     self.ids._dashfigure.figure.close()
        fig = self.cat_contrib_figure('Get category report')
        self.ids._dashfigure.figure = fig
        self.ids._dashfigure.axes = fig.axes[0]
        self.ids._dashfigure.fast_draw = False
        self.ids._dashfigure.figure.canvas.draw_idle()
        self.ids._dashfigure.figure.canvas.flush_events()

    def asp_graph(self, *args):
        selection, *_ = args
        # if self.ids._dashfigure.figure:
        #    self.ids._dashfigure.figure.close()
        fig = self.asp_figure(selection)
        self.ids._dashfigure.figure = fig
        self.ids._dashfigure.axes = fig.axes[0]
        self.ids._dashfigure.fast_draw = False
        self.ids._dashfigure.figure.canvas.draw_idle()
        self.ids._dashfigure.figure.canvas.flush_events()

The issue I am having is that somehow the _dashfigure is not cleared, and figures get superimposed on top of each other. I believe I need to clear the _dashfigure.figure.canvas somehome, but I couldn't figure out how to. Any pointers would be apreciated.

mp-007 commented 1 year ago

Have a screenshot or better a code example. I'm not facing this issue. Also, if you are using the cursor option, you need to update the register_lines

        lines=[]
        for line in self.ids._dashfigure.lines:
            new_line, = fig.axes[0].plot(line.get_xdata(),line.get_ydata(),label=line.get_label())
            lines.append(new_line)

        self.ids._dashfigure.register_lines(lines)

Finally, are you using the latest graph_widget.py from 0.2.0?

mp-007 commented 1 year ago

also maybe the problem is the graph generation. I don't know what self.asp_figure and self.cat_contrib_figure is doing

abedhammoud commented 1 year ago

Hi @mp-007, both self.asp_figure, and self.cat_contrib_figure, create a figure and draw some data. Here for example a summary of self.asp_figure:

    def asp_figure(self, selection):

        fig, ax = plt.subplots(
            nrows=1, ncols=1,
            figsize=(16, 10),
            num=f'{datetime.now():%Y-%m-%d}',
        )

        # get ASP per category dataframe
        AVG = reports.categoryASP(self.erp, selection)

        # graph
        AVG.plot(ax=ax, figsize=(12, 6))
        plt.title(f'Average {cat} unit price')
        plt.ylabel('\N{euro sign}')
        plt.tight_layout()
        return fig

As you see, they just return a matplotlib figure, which contains one or more axes (figures).

BTW, I tried the following, but the fig.axes[0] was giving me an out of index excpection.

    def asp_graph(self, *args):
        selection, *_ = args
        # if self.ids._dashfigure.figure:
        #     self.ids._dashfigure.figure.clear()
        fig = self.asp_figure(selection)
        self.ids._dashfigure.figure = fig
        self.ids._dashfigure.axes = fig.axes[0]
        self.ids._dashfigure.fast_draw = False
        self.ids._dashfigure.figure.canvas.draw_idle()
        self.ids._dashfigure.figure.canvas.flush_events()

Appreciate you trying to help.

abedhammoud commented 1 year ago

BTW, I am using version 0.2.0 version

mp-007 commented 1 year ago

here's an working example

https://user-images.githubusercontent.com/19823482/206933957-cfc55315-8688-4708-a03f-943598139074.mp4


from kivy.utils import platform

#avoid conflict between mouse provider and touch (very important with touch device)
#no need for android platform
if platform != 'android':
    from kivy.config import Config
    Config.set('input', 'mouse', 'mouse,disable_on_activity')

from kivy.lang import Builder
from kivy.app import App
import matplotlib as mpl
import matplotlib.pyplot as plt
from kivy.metrics import dp

#optimized draw on Agg backend
mpl.rcParams['path.simplify'] = True
mpl.rcParams['path.simplify_threshold'] = 1.0
mpl.rcParams['agg.path.chunksize'] = 1000

#define some matplotlib figure parameters
mpl.rcParams['font.family'] = 'Verdana'
mpl.rcParams['axes.spines.top'] = False
mpl.rcParams['axes.spines.right'] = False
mpl.rcParams['axes.linewidth'] = 1.0
import kivy_matplotlib_widget #register all widgets to kivy register

KV = '''

Screen
    figure_wgt:figure_wgt
    BoxLayout:
        orientation:'vertical'
        BoxLayout:
            size_hint_y:0.2
            Button:
                text:"home"
                on_release:app.home()
            ToggleButton:
                group:'touch_mode'
                state:'down'
                text:"pan" 
                on_release:
                    app.set_touch_mode('pan')
                    self.state='down'
            ToggleButton:
                group:'touch_mode'
                text:"zoom box"  
                on_release:
                    app.set_touch_mode('zoombox')
                    self.state='down' 
            ToggleButton:
                group:'touch_mode'
                text:"cursor"  
                on_release:
                    app.set_touch_mode('cursor')
                    self.state='down'                     

        MatplotFigure:
            id:figure_wgt 
        BoxLayout:
            size_hint_y:0.2
            BoxLayout:
                Button:
                    text:"figure1"
                    on_release:app.update_fig1()  
                Button:
                    text:"figure2"
                    on_release:app.update_fig2()                     
'''

class Test(App):
    lines = []

    def build(self):  
        self.screen=Builder.load_string(KV)
        return self.screen

    def on_start(self, *args):
        fig1, ax1 = plt.subplots(1, 1)
        line1=ax1.plot([], [],label=' ')

        xmin,xmax = ax1.get_xlim()
        ymin,ymax = ax1.get_ylim()

        fig1.subplots_adjust(left=0.13,top=0.93,right=0.93,bottom=0.2)

        ax1.set_xlim(xmin, xmax)
        ax1.set_ylim(ymin, ymax)  

        self.screen.figure_wgt.figure = fig1
        self.screen.figure_wgt.axes = ax1
        self.screen.figure_wgt.xmin = xmin
        self.screen.figure_wgt.xmax = xmax
        self.screen.figure_wgt.ymin = ymin
        self.screen.figure_wgt.ymax = ymax
        self.screen.figure_wgt.fast_draw=False

    def update_fig1(self):

        newfig, newax = plt.subplots(1, 1)

        line1=newax.plot([0,1,2,3,4], [1,2,8,9,4],label='line1')

        xmin,xmax = newax.get_xlim()
        ymin,ymax = newax.get_ylim()

        newfig.subplots_adjust(left=0.13,top=0.93,right=0.93,bottom=0.2)

        newax.set_xlim(xmin, xmax)
        newax.set_ylim(ymin, ymax)  

        self.screen.figure_wgt.figure = newfig
        self.screen.figure_wgt.axes = newax
        self.screen.figure_wgt.xmin = xmin
        self.screen.figure_wgt.xmax = xmax
        self.screen.figure_wgt.ymin = ymin
        self.screen.figure_wgt.ymax = ymax
        self.screen.figure_wgt.register_lines(line1)

    def update_fig2(self):

        newfig, newax = plt.subplots(1, 1)

        names = ['group_a', 'group_b', 'group_c']
        values = [1, 10, 100]

        newax.bar(names, values)

        xmin,xmax = newax.get_xlim()
        ymin,ymax = newax.get_ylim()

        newfig.subplots_adjust(left=0.13,top=0.93,right=0.93,bottom=0.2)

        newax.set_xlim(xmin, xmax)
        newax.set_ylim(ymin, ymax)  

        self.screen.figure_wgt.figure = newfig
        self.screen.figure_wgt.axes = newax
        self.screen.figure_wgt.xmin = xmin
        self.screen.figure_wgt.xmax = xmax
        self.screen.figure_wgt.ymin = ymin
        self.screen.figure_wgt.ymax = ymax
        self.screen.figure_wgt.fast_draw=False

    def set_touch_mode(self,mode):
        self.screen.figure_wgt.touch_mode=mode

    def home(self):
        self.screen.figure_wgt.home()

Test().run()
mp-007 commented 1 year ago

I think I understand why it's not working. You say that you are using multiple axis, but all the interactive part in MatplotFigure is for only 1 axis. I will add in the futur 2 axis (twinx) in the code but your case it will better to used matplotlib pan and zoombox. Can you create new issue to handle interactive twin axis and close this one.

Question: when you say multiple axis, do you mean multiple plot or twin axis? image

or

image

kubapilch commented 1 year ago

@mp-007 I was going to create a new issue to add support for multiple axis. I'm using them extensively in a couple of projects. They are somewhat working but the interactive part is not.

Do you think you can look into that sometime soon or should I take a look and create a pull-request?

Edit: I mean twin axis

mp-007 commented 1 year ago

As a temporary solution, I have add a new example in the repo "navigation_bar_general_purpose". This general purpose example is using matplotlib home, pan and zoombox (using NavigationToolbar2 like this repo https://github.com/jeysonmc/kivy_matplotlib). You have also the matplotib cursor x,y of mouse. You don't have the speed as MatPlotFigure with 1 axis and the zoombox is different but it is working.

https://user-images.githubusercontent.com/19823482/206942727-0ddb1ff8-4a27-49ad-a987-dd2aade688e7.mp4

kubapilch commented 1 year ago

@mp-007 Can you provide a link to the example? I cannot find it anywhere.

abedhammoud commented 1 year ago

thanks. My use case involves a lot of axes per figure. I am using this inside a data analytics application, where per figure, I have multiple axes (sometimes 6-7 axes). That said, I believe my use case is more straightforward than what kivy-matplotlib-widget is providing, as I don't need (for now) the zoom and pan per axes. I simply want to take a matplotlib figure object and display it inside a kivy screen. Your examples above show the power of your widget. Well done. I will close this issue and open one for supporting multiple (and not just two) axes. Thanks again.

abedhammoud commented 1 year ago

Will open a new issue for multiple axes.

mp-007 commented 1 year ago

@mp-007 Can you provide a link to the example? I cannot find it anywhere.

Sorry @kubapilch, I didn't push the code last night

https://github.com/mp-007/kivy_matplotlib_widget/tree/main/navigation_bar_general_purpose

mp-007 commented 1 year ago

@kubapilch if you want to test, I add a new experimental widget to handle 2 axis with twinx with lines.

https://github.com/mp-007/kivy_matplotlib_widget/tree/main/example_twinx_experimental