Introduction
This recipe provides a "Fourier Demo" GUI, which is a reimplementation of one of the very first MATLAB GUIs developed at MathWorks in 1993 (right when Handle Graphics was introduced in MATLAB 4). It presents you with two waveforms - a Fourier transform pair - and allows you to manipulate some parameters (via clicking the waveforms and dragging, and controls) and shows how the waveforms are related.
I was very happy about how easily it came together and the performance of the resulting GUI. In particular the matplotlib events and interaction with wx is quite nice. The 'hitlist' of matplotlib figures is very convenient.
What Objects are Involved
This is the main Window of the application, which owns a matplotlib Figure and FigureCanvasWxAgg. It handles mouse interaction (mouseDown, mouseMotion, mouseUp) by registering these methods as callbacks with the canvas.
A Frame that encloses a FourierDemoWindow on top, and two SliderGroups below for the frequency and amplitude control.
A wrapper for a StaticText (for the group's label), TextCtrl, and Slider. Here the Slider and TextCtrl are tied together with the same quanitity.
Knob and Param
- Simple custom classes to handle the connection between multiple widgets that are all tied to the same numerical quantity; see comments in code.
Special Concerns
- Obviously, matplotlib is required for this demo to work!
- numpy is also needed.
- Note this is some of my first wx GUI programming so if you see anything that could be handled with a better pattern / class / etc, I am very open to such suggestions!
Code Sample
1 import numpy as np
2 import wx
3
4 import matplotlib
5 matplotlib.interactive(False)
6 matplotlib.use('WXAgg')
7 from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg
8 from matplotlib.figure import Figure
9 from matplotlib.pyplot import gcf, setp
10
11
12 class Knob:
13 """
14 Knob - simple class with a "setKnob" method.
15 A Knob instance is attached to a Param instance, e.g. param.attach(knob)
16 Base class is for documentation purposes.
17 """
18 def setKnob(self, value):
19 pass
20
21
22 class Param:
23 """
24 The idea of the "Param" class is that some parameter in the GUI may have
25 several knobs that both control it and reflect the parameter's state, e.g.
26 a slider, text, and dragging can all change the value of the frequency in
27 the waveform of this example.
28 The class allows a cleaner way to update/"feedback" to the other knobs when
29 one is being changed. Also, this class handles min/max constraints for all
30 the knobs.
31 Idea - knob list - in "set" method, knob object is passed as well
32 - the other knobs in the knob list have a "set" method which gets
33 called for the others.
34 """
35 def __init__(self, initialValue=None, minimum=0., maximum=1.):
36 self.minimum = minimum
37 self.maximum = maximum
38 if initialValue != self.constrain(initialValue):
39 raise ValueError('illegal initial value')
40 self.value = initialValue
41 self.knobs = []
42
43 def attach(self, knob):
44 self.knobs += [knob]
45
46 def set(self, value, knob=None):
47 self.value = value
48 self.value = self.constrain(value)
49 for feedbackKnob in self.knobs:
50 if feedbackKnob != knob:
51 feedbackKnob.setKnob(self.value)
52 return self.value
53
54 def constrain(self, value):
55 if value <= self.minimum:
56 value = self.minimum
57 if value >= self.maximum:
58 value = self.maximum
59 return value
60
61
62 class SliderGroup(Knob):
63 def __init__(self, parent, label, param):
64 self.sliderLabel = wx.StaticText(parent, label=label)
65 self.sliderText = wx.TextCtrl(parent, -1, style=wx.TE_PROCESS_ENTER)
66 self.slider = wx.Slider(parent, -1)
67 self.slider.SetMax(param.maximum*1000)
68 self.setKnob(param.value)
69
70 sizer = wx.BoxSizer(wx.HORIZONTAL)
71 sizer.Add(self.sliderLabel, 0, wx.EXPAND | wx.ALIGN_CENTER | wx.ALL, border=2)
72 sizer.Add(self.sliderText, 0, wx.EXPAND | wx.ALIGN_CENTER | wx.ALL, border=2)
73 sizer.Add(self.slider, 1, wx.EXPAND)
74 self.sizer = sizer
75
76 self.slider.Bind(wx.EVT_SLIDER, self.sliderHandler)
77 self.sliderText.Bind(wx.EVT_TEXT_ENTER, self.sliderTextHandler)
78
79 self.param = param
80 self.param.attach(self)
81
82 def sliderHandler(self, evt):
83 value = evt.GetInt() / 1000.
84 self.param.set(value)
85
86 def sliderTextHandler(self, evt):
87 value = float(self.sliderText.GetValue())
88 self.param.set(value)
89
90 def setKnob(self, value):
91 self.sliderText.SetValue('%g'%value)
92 self.slider.SetValue(value*1000)
93
94
95 class FourierDemoFrame(wx.Frame):
96 def __init__(self, *args, **kwargs):
97 wx.Frame.__init__(self, *args, **kwargs)
98
99 self.fourierDemoWindow = FourierDemoWindow(self)
100 self.frequencySliderGroup = SliderGroup(self, label='Frequency f0:', \
param=self.fourierDemoWindow.f0)
101 self.amplitudeSliderGroup = SliderGroup(self, label=' Amplitude a:', \
param=self.fourierDemoWindow.A)
102
103 sizer = wx.BoxSizer(wx.VERTICAL)
104 sizer.Add(self.fourierDemoWindow, 1, wx.EXPAND)
105 sizer.Add(self.frequencySliderGroup.sizer, 0, \
wx.EXPAND | wx.ALIGN_CENTER | wx.ALL, border=5)
106 sizer.Add(self.amplitudeSliderGroup.sizer, 0, \
wx.EXPAND | wx.ALIGN_CENTER | wx.ALL, border=5)
107 self.SetSizer(sizer)
108
109
110 class FourierDemoWindow(wx.Window, Knob):
111 def __init__(self, *args, **kwargs):
112 wx.Window.__init__(self, *args, **kwargs)
113 self.lines = []
114 self.figure = Figure()
115 self.canvas = FigureCanvasWxAgg(self, -1, self.figure)
116 self.canvas.callbacks.connect('button_press_event', self.mouseDown)
117 self.canvas.callbacks.connect('motion_notify_event', self.mouseMotion)
118 self.canvas.callbacks.connect('button_release_event', self.mouseUp)
119 self.state = ''
120 self.mouseInfo = (None, None, None, None)
121 self.f0 = Param(2., minimum=0., maximum=6.)
122 self.A = Param(1., minimum=0.01, maximum=2.)
123 self.draw()
124
125 # Not sure I like having two params attached to the same Knob,
126 # but that is what we have here... it works but feels kludgy -
127 # although maybe it's not too bad since the knob changes both params
128 # at the same time (both f0 and A are affected during a drag)
129 self.f0.attach(self)
130 self.A.attach(self)
131 self.Bind(wx.EVT_SIZE, self.sizeHandler)
132
133 def sizeHandler(self, *args, **kwargs):
134 self.canvas.SetSize(self.GetSize())
135
136 def mouseDown(self, evt):
137 if self.lines[0] in self.figure.hitlist(evt):
138 self.state = 'frequency'
139 elif self.lines[1] in self.figure.hitlist(evt):
140 self.state = 'time'
141 else:
142 self.state = ''
143 self.mouseInfo = (evt.xdata, evt.ydata, max(self.f0.value, .1), self.A.value)
144
145 def mouseMotion(self, evt):
146 if self.state == '':
147 return
148 x, y = evt.xdata, evt.ydata
149 if x is None: # outside the axes
150 return
151 x0, y0, f0Init, AInit = self.mouseInfo
152 self.A.set(AInit+(AInit*(y-y0)/y0), self)
153 if self.state == 'frequency':
154 self.f0.set(f0Init+(f0Init*(x-x0)/x0))
155 elif self.state == 'time':
156 if (x-x0)/x0 != -1.:
157 self.f0.set(1./(1./f0Init+(1./f0Init*(x-x0)/x0)))
158
159 def mouseUp(self, evt):
160 self.state = ''
161
162 def draw(self):
163 if not hasattr(self, 'subplot1'):
164 self.subplot1 = self.figure.add_subplot(211)
165 self.subplot2 = self.figure.add_subplot(212)
166 x1, y1, x2, y2 = self.compute(self.f0.value, self.A.value)
167 color = (1., 0., 0.)
168 self.lines += self.subplot1.plot(x1, y1, color=color, linewidth=2)
169 self.lines += self.subplot2.plot(x2, y2, color=color, linewidth=2)
170 #Set some plot attributes
171 self.subplot1.set_title("Click and drag waveforms to change frequency and amplitude", fontsize=12)
172 self.subplot1.set_ylabel("Frequency Domain Waveform X(f)", fontsize = 8)
173 self.subplot1.set_xlabel("frequency f", fontsize = 8)
174 self.subplot2.set_ylabel("Time Domain Waveform x(t)", fontsize = 8)
175 self.subplot2.set_xlabel("time t", fontsize = 8)
176 self.subplot1.set_xlim([-6, 6])
177 self.subplot1.set_ylim([0, 1])
178 self.subplot2.set_xlim([-2, 2])
179 self.subplot2.set_ylim([-2, 2])
180 self.subplot1.text(0.05, .95, r'$X(f) = \mathcal{F}\{x(t)\}$', \
verticalalignment='top', transform = self.subplot1.transAxes)
181 self.subplot2.text(0.05, .95, r'$x(t) = a \cdot \cos(2\pi f_0 t) e^{-\pi t^2}$', \
verticalalignment='top', transform = self.subplot2.transAxes)
182
183 def compute(self, f0, A):
184 f = np.arange(-6., 6., 0.02)
185 t = np.arange(-2., 2., 0.01)
186 x = A*np.cos(2*np.pi*f0*t)*np.exp(-np.pi*t**2)
187 X = A/2*(np.exp(-np.pi*(f-f0)**2) + np.exp(-np.pi*(f+f0)**2))
188 return f, X, t, x
189
190 def repaint(self):
191 self.canvas.draw()
192
193 def setKnob(self, value):
194 # Note, we ignore value arg here and just go by state of the params
195 x1, y1, x2, y2 = self.compute(self.f0.value, self.A.value)
196 setp(self.lines[0], xdata=x1, ydata=y1)
197 setp(self.lines[1], xdata=x2, ydata=y2)
198 self.repaint()
199
200
201 class App(wx.App):
202 def OnInit(self):
203 self.frame1 = FourierDemoFrame(parent=None, title="Fourier Demo", size=(640, 480))
204 self.frame1.Show()
205 return True
206
207 app = App()
208 app.MainLoop()
Comments
Email the author at tpk@kraussfamily.org for comments and questions.
