CS71 Lab 4: Photobooth

As far as the customer is concerned, the interface is the product --Jef Raskin


Due Sunday, March 3, before midnight


The goals for this lab assignment are to


Getting started

By this point in the semester, everyone should have a github account. Most people are in teams of two for this assignment, so your repository should have the form

$ cd cs71
$ git clone git@github.swarthmore.edu:cs71-s19/03-photobooth-USER1-USER2.git 

If you are working in a team, be professional. As part of your homework submission, you will be asked to fill out a performance evaluation of your teammate.


Photobooth

In this assignment, you will implement an application inspired by Instagram's face filters. This application allows you to add images (called accessories) to a base image and save the result. The user can position, scale, and delete the changes they make to the base image.

Your implementation should be based on the design documentation of the following sections. Please skim through the document before starting.

I recommend implementing the application one "user story" at a time.

We will use the following terms throughout:


User interface overview

The application interface consists of a main window which contains a


User stories

Each user story represents a feature that the application should support. Implementing a user story may require modifying several classes. You should test each user story before implementing a new one.

1. Quitting from the menu bar

The user can quit the application by selecting File -> Quit

2. Quitting by closing the main window

The user can quit the application by closing the main window. The main window can be closed from the 'little X' on the top right corner, or from the window menu.

3. Opening an image

The user can change the base image (stored in the CompositeModel) by selecting File -> Open. This option should open a dialog box to allow the user to browse for a file. If the file cannot be loaded, you should display a message box to tell the user.

4. Saving an image

The user can save the composite image (stored in the CompositeModel) by selecting File -> Save As. This option should prompt the user for a filename and then save the file. If the file cannot be saved, you should display a message box to tell the user.

5. Adding an accessory

The user can add an accessory to the base image by clicking any of the accessory buttons from the tool bar (for example, star, heart, glasses, moustache, tiara, etc). Adding an accessory should create a new layer. The new layer should be listed in the left ListView and selected by default. The image corresponding the new layer should be shown on the Canvas (at an arbitrary position).

6. Deleting an accessory

The user can delete an accessory by selecting it from the ListView and pushing the delete button.

7. Moving an accessory

The user can move an accessory by first selecting its layer and activating the Move tool from the toolbar. Using the tool, they can left click and drag on the Canvas to change the position of the accessory.

8. Scaling an accessory

The user can scale an accessory by first selecting its layer and activating the Move tool from the toolbar. Using the tool, they can left click and drag on the Canvas to change the rotation of the accessory.


Architecture overview

This application uses the model-view-controller design pattern to separate the management of the composite image from the display and user editing. This design has several advantages:

The major components of the application are the front-end, managed by the MainWindow, and the back-end, managed by the CompositeModel class.

You will implement the following classes:

Containership Diagram

Containership Diagram

Back-end model

The backend model is implemented in CompositeModel. The CompositeModel class manages a list of layers, e.g. supports an API for adding, editing, and deleting layers. It also supports loading the base image and saving the composite image.

Each Layer represents an accessory that the user added to the base image. Whenever the user changes a layer, the composite image recomputes itself by re-applying each layer with the original image. Users (such as the front-end) can register a callback to learn when the composite image changes.

All raw images are represented using Gdk.Pixbuf, which is a cross-platform image buffer class.

CompositeModel specification

Your implementation must implement the public API specified below but you may define additional helper functions if you like. Unless you have good reason to do otherwise, all member variables should be declared private.

// Private data members
list of layers of type `List<Layer>` 
base image of type `Gdk.Pixbuf` 
composite image of type `Gdk.Pixbuf` 
list of callbacks to be invoked when the composite image changes
list of callbacks to be invoked when the layer list changes

// Constructors
public CompositeModel()

// Public Properties
CompositeImage // returns composite image; not settable
NumLayers // returns number of layers; not settable

// Public methods

// requires: non-null callback method or listener class
// effects: removes the callback from the list of layer cbs; noop if not found
public void RemoveLayerChangedCallback

// requires: non-null callback method or listener class
// effects: adds the callback to the list of layer cbs
public void AddLayerChangedCallback

// requires: non-null callback method or listener class
// effects: removes the callback from the list of composite image cbs; noop if not found
public void RemoveCompositeChangedCallback

// requires: non-null callback method or listener class
// effects: adds the callback to the list of composite cbs
public void AddCompositeChangedCallback

// requires: 0 <= id < NumLayers
// effects: removes layer with id from our list; 
//     updates the composite image; and invokes composite and layer cbs
public void DeleteLayer(int id)

// requires: 0 <= id < NumLayers
// effects: scales the layer with id uniformly by size; 
//     updates the composite image; and invokes composite image cbs
public void ScaleLayer(int id, double size)

// requires: 0 <= id < NumLayers
// effects: moves layer with id to position X (horizontal) and Y (vertical) 
//     relative to the base image's top left corner; 
//     updates the composite image; and invokes composite image cbs
public void MoveLayer(int id, double x, double y)

// requires: 0 <= id < NumLayers
// effects: Returns the name of layer with id (note: can be "")
public string GetLayerName(int id)

// requires: non-empty pixels
// effects: Adds a new layer having the given image and name; invokes layer cbs
public void AddLayer(Gdk.Pixbuf pixels, string name)

// requires: nothing
// effects: Saves the image with the given filename. 
//     Returns true if successful; false otherwise. 
public bool SaveCompositeImage(string filename)

// requires: nothing
// effects: Sets the base image with the given filename. 
//     Initializes the compoiste image.
//     Returns true if successful; false otherwise. 
public bool LoadBaseImage(string filename)

Event flows

For this design, all callbacks should be in your class MainWindow. Recall that a callback is any function that's invoked when an event happens. The MainWindow's widgets and CompositeModel are all publishers. MainWindow a subscriber.

Publishers and Subscribers

Publishers and Subscribers

In this design, all publishers communicate with each other only via MainWindow. This design keeps all our callbacks in one place, making their execution and logic easier to keep track of. It's as if all the publishers 'are mad each other' and will only talk to MainWindow. For example,

Example: Implementing User Story #3: Opening an image

In this section, we will talk about the details of implementing the following conversation:

Step 1 To detect clicks on a menu item, you need to register a callback on MenuItem.Activated (see tutorial). Your callback will be called when the user selects the menu item.

Step 2 In the callback, MainWindow should use a FileDialog to ask the user for a filename. This filename should be passed to CompositeMode.LoadBaseImage. If the filename is invalid, CompositeModel.LoadBaseImage will return false. In this case, MainWindow should tell the user (see MessageBox).

Step 3 If the filename is valid, CompositeModel.LoadBaseImage needs to initialize the class with the new image. Therefore, it should

This is also a good time to implement the properties CompositeModel.CompositeImage and CompositeModel.NumLayers too.

Step 4 With the CompositeModel initialized you can now update the Canvas by calling SetImage.

The simplest way to implement this user story is with a callback that looks like:

void OnOpenCallback(.....)
{
   // Ask user for a filename
   // Call LoadBaseImage on your composite model
   // Call SetImage on your canvas
   // NOTE: MainWindow should store references to its model and canvas 
   // as private member variables
}

However, updating the canvas like above means the main window has to keep track of when the composite model changes. This will become harder and harder to manage as you implement more user stories. The better way is to update the canvas in a callback registered on CompositeModel like so

void OnOpenCallback(.....)
{
   // Ask user for a filename
   // Call LoadBaseImage on your composite model
   // NOTE: MainWindow should store references to its model and canvas 
   // as private member variables
}

void OnCompositeImageChangeCallback(Gdk.Pixbuf img)
{
   // Call SetImage() on your canvas
}

This design updates the canvas in a single place. The logic for determining when the canvas should change can now be in CompositeModel, not MainWindow. For example, you could implement a method CompositeModel.InvokeCompositeChangeCallbacks which notifies all the subscribers that the composite image has changed. InvokeCompositeChangedCallbacks is then called whenever a call to CompositeModel changes the composite image.

This interaction diagram shows the execution flow based on events.

Interaction Diagram for Open (valid filename)

Interaction Diagram for Open (valid filename)

I recommend implementing the callback API for CompositeModel before implementing your layer user stories. However, feel free to implement the first few user stories without them if you find it easier to understand. But don't forget to refactor your code to use the callback API later!

Generic event chain for Menubar events

Generic event chain for Toolbar events

Generic event chain for button events

Generic event chain for mouse events on the Canvas (needed for move and scale)


Tips and tricks

1. How can I can I figure out how to use the widgets from GTK#?

The API documentation is here. The class examples repository has several example programs under /gui to get you started with small programs.

Below are links to some widgets you'll likely need for your assignment.

2. How do I program a callback API for the CompositeModel class?

You should implement a Publisher/Subscriber pattern to support callbacks. The CompositeModel is the publisher and the GUI is the subscriber in this case. This allows the GUI to 'know' about the Model without the Model needing to worry about the GUI. You can use either Listeners or function objects. In your class examples repository, there are examples for both.

3. How can I apply a layer to the composite image?

Call Layer.Apply()

4. How can I reset the composite image to the original image before I apply each Layer?

Call Copy on the original image

5. Is it possible to automatically load all the images in /accessories so I don't have to hard-code each one?

Yes! Use DirectoryInfo.GetFiles to loop through the list of files from a directory.

6. How can I set the size of the image for the toolbar to something small?

Resize the image using Pixbuf.ScaleSimple


BONUS: Try implementing the following features to improve the interface

void SetCursor(Gdk.Pixbuf src)
{
   int x = src.Width/2;
   int y = src.Height/2;
   Gdk.Cursor cursor = new Gdk.Cursor(Screen.Display, src, x, y);
   Screen.RootWindow.Cursor = cursor;
}

void ResetCursor()
{
   Gdk.Cursor cursor = new Gdk.Cursor(Gdk.CursorType.Arrow);
   Screen.RootWindow.Cursor = cursor;
}

Grading Rubric

Your implementation of CompositeModel will be tested against an existing implementation of MainWindow. Similarly, your implementation of MainWindow will be tested against an existing implementation of CompositeModel.


(5 points) Turning in your labs

Update the README.md file with any feedback on the assignment, how long it took you to do, and what you found most challenging.

Please fill out a performance evaluation of your teammate.

Use git to hand-in your assignment.

$ git status
$ git add .
$ git status
$ git commit -m "description of my awesome work"
$ git push

NOTES