Python track: lab 5: fun with graphics


This lab, you're going to use the one-dimensional cellular automaton class you designed last lab to generate interesting graphical images.

Graphics in python

Doing graphics in python is both a blessing and a curse. It's a blessing for two reasons:

  1. It's much easier to write programs that do simple graphics in python than in virtually any other language.
  2. There are python bindings to a huge number of graphical toolkits and environments, including the java AWT and Swing toolkits, the tcl Tk toolkit, Gtk, wxWindows, Qt, as well as Windows-specific graphical environments.

The downsides are these:

  1. Because of the abundance of choices, you need to carefully weigh the different toolkits against each other to know which is most appropriate for your task. This is often non-trivial.
  2. Graphics in python are often slower than those written directly in (say) C.

In this assignment, we will use the simplest graphical toolkit, called Tkinter. This is an interface to the Tk toolkit designed originally for the scripting language Tcl ("tool command language").

Programming graphics in python using Tkinter

Getting started

Moderately good on-line documentation on Tkinter is available here. In what follows, we'll discuss only the aspects of Tkinter that are relevant to our task.

Using Tkinter in python requires that you use the Tkinter module. The usual way to do this is to put this as the first line of your program:

    from Tkinter import *

You could also write this:

    import Tkinter

but this would end up being cumbersome, since you will need to call a lot of Tkinter commands, and writing Tkinter.XXX (where XXX is the name of some Tk function) over and over again is tedious.

Tkinter supports a wide range of graphical objects (known as "widgets" in graphics terminology) that allow for the construction of sophisticated graphical user interfaces (GUIs). Examples of these include buttons, labels, sliders, menus, checkboxes, and so forth. In this assignment, we will ignore all of these and concentrate on the Tkinter canvas widget, which is a general-purpose drawing area where different kinds of lines and shapes can be drawn. In fact, the only shapes we will need to draw are square colored boxes.

To get started, you need to create a Tk object, e.g.

    tk = Tk()

This will create a small window on the screen. You can set the size of the window as follows:

    tk.wm_geometry("800x600+20+40")

The string means to make an 800x600 pixel window whose upper left-hand corner is at screen coordinates (20,40). The "wm" in "wm_geometry" means "window manager", which is a program which controls the sizes and positions of windows on your screen (among other things).

Creating the canvas

The canvas object is created as follows:

    canvas = Canvas(tk, width=800, height=600)
    canvas.pack()

The first line creates the canvas, while the second one places it on the screen relative to the top-level frame (the frame created by the original Tk object). The exact way it does this is not important here.

Drawing colored boxes on the canvas

This code:

    a = canvas.create_rectangle(0, 0, 50, 50,
                                fill="red",
                                outline="red")  # or: outline=""

will draw a single red box on the canvas whose upper left-hand corner is located at canvas coordinates (0,0) and whose lower-right-hand corner is located at canvas coordinates (50, 50). If we didn't specify the outline color as red (or, equivalently as far as this function is concerned, as "") the box would be drawn with a black outline. For our purposes we won't want a black outline.

Note also that the create_rectangle call creates a "handle" to the colored box that can be bound to a variable (here, it's bound to the variable a).

Moving graphical objects

If we do this:

    canvas.move(a, 0, 50)

it will move the red box down 50 pixels in the y direction (vertically down the screen). This ability to label objects and manipulate them as units is very powerful and convenient.

Binding keys to functions

Suppose you wanted to specify that each time you hit e.g. the return key while your program was running, you would perform some action (e.g. saving the image on the canvas to a postscript file called "canvas.ps"). First of all, you have to define a function that will perform the action you want. In Tkinter, this is very easy:

    def save_as_postscript(e):
        canvas.postscript(file="canvas.ps")

Note that postscript is a file format that is used for graphical images that are to be sent to a printer to produce paper output. You can look at a postscript file on your computer screen if you have what's called a "postscript previewer" program; on the CS cluster machines the "gv" program is a postscript previewer you can use.

The e argument stands for "event"; it represents the kind of event that generated the function call. We don't care about that here, because we will only use the return key to generate this function call. Nevertheless, you have to include the e argument because it will be passed to the function when the function is invoked, even though we won't use it.

Then you have to bind the return key to the action. That's done as follows:

    tk.bind("<Return>", save_as_postscript)

Writing the event loop

Normally, graphical programs that use Tkinter are written in "event-driven" style. This means that the user does something to the graphical interface (e.g. clicks a button, drags the mouse cursor across a canvas with the left button down, etc.), and the computer does something in response to the user action (called an "event"). In order to initiate this process, you have to start an "event loop", which is normally done like this:

    tk.mainloop()

This will start the event loop, which will listen for events and execute commands based on those events. This function call will never return.

However, sometimes you need more control than this simple event loop involves. You may need to do some computation and occasionally interrupt it to process some event. The program you have to write for this lab is an example of this. In this case, you can write a trivial event loop of your own like this:

    done = 0
    
    def finish(e):
        global done
        done = 1
    
    tk.bind("<Control-c>", finish)
    
    while not done:
        # Code for regular processing goes here.
        tk.update() # check for events

Note that changing global variables within a function requires the global declaration, which we use in the function finish. A global declaration is only needed when you want to change the value of a global variable from inside a function or method.

Now the program does its regular processing as part of the while loop. Any events that might happen while this processing occurs are saved in a queue; which is emptied when the tk.update() call occurs. As the events are removed from the queue, they are executed. So, for instance, when you press <Control-c> then "done" is set to 1 (true) and the while loop will finish.

Wrapping everything in an object

In general, it is not a good idea to define everything in the procedural style we've been using above, and it's also not a good idea to use global variables when you can avoid it. Instead, you define your graphical application as an object and you use methods instead of functions and fields instead of global variables. This improves the modularity of your code and makes it easier to maintain. In general, these objects have only one instance (called "singleton objects" in object-oriented terminology).

The code we've written above, suitably objectified, can be found here. You should use this as a template for designing your program. You'll also want to add docstrings to the methods for better documentation.

Description of the program

Your program should use your cellular automaton class from lab 4 to compute the output of a cellular automaton, and then the code you write for this lab will display that output in graphical form on the canvas. You should not re-write any of the cellular automaton class from lab 4; instead, import it and use it by invoking its methods. Your display should output the states of the automaton by printing a new horizontal line of colored boxes in the canvas widget for each new generation of the automaton. In other words, each cell of the automaton will be represented by single colored square box, with cell[0] at the left. The color of each cell's box should have a one-to-one correspondence with the possible states. The states in the first line of the simulation should be randomly selected from among the available states (you wrote a method to do this in lab 4). Successive generations (time steps) of the automaton are displayed as successive horizontal lines of squares, with the earliest generations at the top of the canvas. Initially, successive generations are added to the display until it's full. After this, the display should scroll upwards so that the most recent generations are visible (while the older ones scroll off the screen). NOTE: scrolling the display will probably be much slower than simply drawing new boxes onto the display.

You should be able to specify the size of the cell array, the number of states (up to at least 4), and the number of neighbors as command-line arguments to your program. For simplicity, you can allow the state transition table to be randomly generated. I recommend a cell array between 80 and 160 cells long for nice displays. Your program should adjust the size of the squares to completely fill the horizontal extent of the canvas. You shouldn't scroll until you've reached the bottom of the canvas. Also, make sure that when you scroll the canvas you don't just move all the squares up one row; you also have to delete the squares that have scrolled off the screen. This is done with the delete() method of the canvas object. The argument to the method is the handle of the Tk object you want deleted (here, the square). If you don't do that your program will run much more slowly and your postscript output will be... unusual.

You should have (separate) key bindings for output to a postscript file and for stopping the simulation.

As usual, you should print out a full usage message if incorrect arguments are given to the program. However, the TkAutomaton class shouldn't do any command-line argument processing; that should be done at the outermost level of the program. (Design question: why do you think this is?)

The name of the file containing your program should be automaton_display.py.

That's it! Have fun with this; you can generate some very pretty patterns.