-1

I have a sensor that needs to be calibrated. The error depends on the orientation of the sensor and can be estimated and shown to the user. I would like to do this visually using tkinter for python 3.x.

The ideal result would be something like this with the black bar live updating depending on the live error:

calibration bar

How could I do this best in tkinter? I looked at the Scale and Progressbar widgets but they did not have the needed functionality.

I was thinking about showing the colorbar as an image and overlaying the black indicator bar and constantly updating the position of this black bar. Would this be possible?

martineau
  • 119,623
  • 25
  • 170
  • 301
ThaNoob
  • 520
  • 7
  • 23
  • Yes, you could do it the way you're thinking. The "tricky" part probably won't be doing the graphics. Instead it will dealing with the live updating from within the `tkinter`-based application. This will need to be done by polling a data source periodically. The data could obtained directly from the hardware or a background thread could do that and put values in a `Queue` that's shared with the main GUI thread using `tkinter` (and the main thread can poll the Queue to get it). I would split the work up into two parts—one part for creating a `CalibrarionBar` widget and a second for the data. – martineau Jan 15 '19 at 14:20
  • So in order to get this to work I will have to start multithreading? Is tkinter the easiest tool to make something like this work in Python? – ThaNoob Jan 15 '19 at 14:25
  • 1
    Using multithreading isn't a requirement. It would be fairly easy to periodically execute a function that obtained the data and did something with it. I believe these issues would have to addressed regardless of what GUI toolkit you used. The reason is they're all user-event "driven" which means they all have a `tkinter`-like main processing loop that needs to be alive most of the time or the application will appear to "hang" or stall while that's stopped. – martineau Jan 15 '19 at 14:33
  • Okay thanks, do you have any recommendations on how to display the two images on top of each other? – ThaNoob Jan 15 '19 at 14:39
  • You don't need two images. Just fill a `tkinter.Canvas` with the color gradient with the indicator line drawn on top of that. The position of the indicator can be updated as necessary. `Canvas` widgets contain graphic "object"s which can be updated individually. Here's a little [documentation](http://infohost.nmt.edu/tcc/help/pubs/tkinter/web/canvas.html). I've posted a number of answers here about drawing color gradients, btw. Here's [one](https://stackoverflow.com/questions/20792445/calculate-rgb-value-for-a-range-of-values-to-create-heat-map/20793850#20793850). – martineau Jan 15 '19 at 14:50
  • Note that the color gradient could also be a pre-built image file, which wouldn't need to be recreated every time the program starts up—although that shouldn't take very long. – martineau Jan 15 '19 at 15:01
  • Thanks a lot, I will try it out and let you know the result. It might still take a while before I get it all figured out though – ThaNoob Jan 15 '19 at 22:38
  • Give the graphics a shot first and try to let me know if you get stuck by adding another comment here with @martineau in it. – martineau Jan 15 '19 at 22:52
  • 1
    @martineau will do that, but I just got another assignment from my boss that is a little more urgent so it might be for next week. I will keep you posted. – ThaNoob Jan 15 '19 at 23:16
  • 1
    Finished it, thanks for your advice @martineau – ThaNoob Jan 29 '19 at 14:05

1 Answers1

1

I shall split up the answer in two parts. The first part solves the issue of live updating the data, by using two threads as suggested by @Martineau. The communication between the threads is done by a simple lock and a global variable.

The second part creates the calibration bar widget using the gradient calculation algorithm defined by @Martineau.

PART 1: This example code shows a small window with one number. The number is generated in one thread and the GUI is shown by another thread.

import threading
import time
import copy
import tkinter as tk
import random

class ThreadCreateData(threading.Thread):
    def __init__(self, name):
        threading.Thread.__init__(self)
        self.name = name

    def run(self):
        #Declaring data global allows to access it between threads
        global data

        # create data for the first time
        data_original = self.create_data()

        while True:  # Go in the permanent loop
            print('Data creator tries to get lock')
            lock.acquire()
            print('Data creator has it!')
            data = copy.deepcopy(data_original)
            print('Data creator is releasing it')
            lock.release()
            print('Data creator is creating data...')
            data_original = self.create_data()

    def create_data(self):
        '''A function that returns a string representation of a number changing between one and ten.'''
        a = random.randrange(1, 10)
        time.sleep(1) #Simulating calculation time
        return str(a)


class ThreadShowData(threading.Thread):
    def __init__(self, name):
        threading.Thread.__init__(self)
        self.name = name

    def run(self):
        # Declaring data global allows to access it between threads
        global data

        root = tk.Tk()

        root.geometry("200x150")

        # creation of an instance
        app = Window(root, lock)

        # mainloop
        root.mainloop()



# Here, we are creating our class, Window, and inheriting from the Frame
# class. Frame is a class from the tkinter module. (see Lib/tkinter/__init__)
class Window(tk.Frame):

    # Define settings upon initialization. Here you can specify
    def __init__(self, master=None,lock=None):

        # parameters that you want to send through the Frame class.
        tk.Frame.__init__(self, master)

        # reference to the master widget, which is the tk window
        self.master = master

        #Execute function update_gui after 1ms
        self.master.after(1, self.update_gui(lock))

    def update_gui(self, lock):
        global data
        print('updating')

        print('GUI trying to get lock')
        lock.acquire()
        print('GUI got the lock')

        new_data = copy.deepcopy(data)
        print('GUI releasing lock')
        lock.release()

        data_label = tk.Label(self.master, text=new_data)
        data_label.grid(row=1, column=0)

        print('GUI wating to update')
        self.master.after(2000, lambda: self.update_gui(lock)) #run update_gui every 2 seconds

if __name__ == '__main__':
    # creating the lock
    lock = threading.Lock()

    #Initializing data
    data = None

    #creating threads
    a = ThreadCreateData("Data_creating_thread")
    b = ThreadShowData("Data_showing_thread")

    #starting threads
    b.start()
    a.start()

PART 2: Below the code for a simple calibration bar widget is shown. The bar only contains 5 ticks you can adapt the code to add more if wanted. Pay attention to the needed input formats. To test the widget a random value is generated and shown on the widget every 0.5s.

import tkinter as tk
from PIL import ImageTk, Image
import sys
EPSILON = sys.float_info.epsilon  # Smallest possible difference.

###Functions to create the color bar (credits to Martineau)
def convert_to_rgb(minval, maxval, val, colors):
    for index, color in enumerate(colors):
        if color == 'YELLOW':
            colors[index] = (255, 255, 0)
        elif color == 'RED':
            colors[index] = (255, 0, 0)
        elif color == 'GREEN':
            colors[index] = (0, 255, 0)
    # "colors" is a series of RGB colors delineating a series of
    # adjacent linear color gradients between each pair.
    # Determine where the given value falls proportionality within
    # the range from minval->maxval and scale that fractional value
    # by the total number in the "colors" pallette.
    i_f = float(val - minval) / float(maxval - minval) * (len(colors) - 1)
    # Determine the lower index of the pair of color indices this
    # value corresponds and its fractional distance between the lower
    # and the upper colors.
    i, f = int(i_f // 1), i_f % 1  # Split into whole & fractional parts.
    # Does it fall exactly on one of the color points?
    if f < EPSILON:
        return colors[i]
    else:  # Otherwise return a color within the range between them.
        (r1, g1, b1), (r2, g2, b2) = colors[i], colors[i + 1]
        return int(r1 + f * (r2 - r1)), int(g1 + f * (g2 - g1)), int(b1 + f * (b2 - b1))

def create_gradient_img(size, colors):
    ''''Creates a gradient image based on size (1x2 tuple) and colors (1x3 tuple with strings as entries,
    possible entries are GREEN RED and YELLOW)'''
    img = Image.new('RGB', (size[0],size[1]), "black") # Create a new image
    pixels = img.load() # Create the pixel map
    for i in range(img.size[0]):    # For every pixel:
        for j in range(img.size[1]):
            pixels[i,j] = convert_to_rgb(minval=0,maxval=size[0],val=i,colors=colors) # Set the colour accordingly

    return img

### The widget
class CalibrationBar(tk.Frame):
    """"The calibration bar widget. Takes as arguments the parent, the start value of the calibration bar, the
    limits in the form of a 1x5 list these will form the ticks on the bar and the boolean two sided. In case it
    is two sided the gradient will be double."""
    def __init__(self, parent,  limits, name, value=0, two_sided=False):
        tk.Frame.__init__(self, parent)

        #Assign attributes
        self.value = value
        self.limits = limits
        self.two_sided = two_sided
        self.name=name

        #Test that the limits are 5 digits
        assert len(limits)== 5 , 'There are 5 ticks so you should give me 5 values!'

        #Create a canvas in which we are going to put the drawings
        self.canvas_width = 400
        self.canvas_height = 100
        self.canvas = tk.Canvas(self,
                                width=self.canvas_width,
                                height=self.canvas_height)

        #Create the color bar
        self.bar_offset = int(0.05 * self.canvas_width)
        self.bar_width = int(self.canvas_width*0.9)
        self.bar_height = int(self.canvas_height*0.8)
        if two_sided:
            self.color_bar = ImageTk.PhotoImage(create_gradient_img([self.bar_width,self.bar_height],['RED','GREEN','RED']))
        else:
            self.color_bar = ImageTk.PhotoImage(create_gradient_img([self.bar_width,self.bar_height], ['GREEN', 'YELLOW', 'RED']))

        #Put the colorbar on the canvas
        self.canvas.create_image(self.bar_offset, 0, image=self.color_bar, anchor = tk.NW)

        #Indicator line
        self.indicator_line = self.create_indicator_line()

        #Tick lines & values
        for i in range(0,5):
            print(str(limits[i]))
            if i==4:
                print('was dees')
                self.canvas.create_line(self.bar_offset + int(self.bar_width - 2), int(self.canvas_height * 0.7),
                                        self.bar_offset + int(self.bar_width - 2), int(self.canvas_height * 0.9), fill="#000000", width=3)
                self.canvas.create_text(self.bar_offset + int(self.bar_width - 2), int(self.canvas_height * 0.9), text=str(limits[i]), anchor=tk.N)
            else:
                self.canvas.create_line(self.bar_offset + int(i * self.bar_width / 4), int(self.canvas_height * 0.7), self.bar_offset + int(i * self.bar_width / 4), int(self.canvas_height * 0.9), fill="#000000", width=3)
                self.canvas.create_text(self.bar_offset + int(i * self.bar_width / 4), int(self.canvas_height * 0.9), text=str(limits[i]), anchor=tk.N)

        #Text
        self.label = tk.Label(text=self.name+': '+str(self.value),font=14)

        #Positioning
        self.canvas.grid(row=0,column=0,sticky=tk.N)
        self.label.grid(row=1,column=0,sticky=tk.N)

    def create_indicator_line(self):
        """"Creates the indicator line"""
        diff = self.value-self.limits[0]
        ratio = diff/(self.limits[-1]-self.limits[0])
        if diff<0:
            ratio=0
        elif ratio>1:
            ratio=1
        xpos = int(self.bar_offset+ratio*self.bar_width)
        return self.canvas.create_line(xpos, 0, xpos, 0.9 * self.canvas_height, fill="#000000", width=3)

    def update_value(self,value):
        self.value = value
        self.label.config(text = self.name+': '+str(self.value))
        self.canvas.delete(self.indicator_line)
        self.indicator_line = self.create_indicator_line()


###Creation of window to place the widget
class App(tk.Tk):
    def __init__(self):
        tk.Tk.__init__(self)
        self.geometry('400x400')
        self.calibration_bar = CalibrationBar(self, value= -5, limits=[-10, -5, 0, 5, 10], name='Inclination angle', two_sided=True)
        self.calibration_bar.grid(column=0, row=4)

        self.after(500,self.update_data)

    def update_data(self):
        """"Randomly assing values to the widget and update the widget."""
        import random

        a = random.randrange(-15, 15)
        self.calibration_bar.update_value(a)

        self.after(500, self.update_data)



###Calling our window
if __name__ == "__main__":
    app=App()
    app.mainloop()

This is how it looks like:

Calibration bar screenshot

To get a live updating calibration bar you should just combine part one and two in your application.

ThaNoob
  • 520
  • 7
  • 23
  • By George, I think he's got it! `;¬)` – martineau Jan 29 '19 at 14:38
  • BTW, `Locks` are [Context Managers](https://docs.python.org/3/reference/datamodel.html#context-managers) objects which means that instead of `lock.acquire()`...`lock.release()` you can use them in [`with`](https://docs.python.org/3/reference/compound_stmts.html#the-with-statement) statements. e.g. `with lock:` followed by the (indented) statement(s) you want to execute after acquiring it such as `data = copy.deepcopy(data_original)`. – martineau Jan 29 '19 at 14:52
  • Also note one generally shouldn't call `time.sleep()` in `tkinter` apps because it makes them "hang" for the duration since it blocks the main thread's execution of `mainloop()`. Instead use the universal widget [`after()`](http://infohost.nmt.edu/tcc/help/pubs/tkinter/web/universal.html) method _without a function callback specified_. That works because it's part of and integrated into `tkinter` so using it allows `mainloop()` to keep running. – martineau Jan 29 '19 at 15:12
  • I know, but I am not using time.sleep() in the mainloop() of the GUI. I am using it in the data creation thread to simulate the time it takes to do the calculations and retrieve the data from the sensors. – ThaNoob Jan 29 '19 at 16:27
  • Oh, that's right—yeah, it's fine to use `time.sleep()` in the non-tkinter thread. Different topic: With regards to drawing the gradient, consider using PIL's [`ImageDraw`](https://pillow.readthedocs.io/en/stable/reference/ImageDraw.html) module which would be much faster than drawing individual pixels as is currently being done. Another possibility would be to use the `Canvas` drawing methods and render the gradient right onto it that way (instead of creating images with PIL). – martineau Jan 29 '19 at 16:41
  • I am sure that that would be a much more efficient, but that's not really an issue for my application as the bars are only loaded once in the beginning and you can't really notice any lagging in the application because of it. – ThaNoob Jan 30 '19 at 09:59