OO Python Character Animation Design Study
RobertLechte (Talk | contribs) |
|||
(10 intermediate revisions by 2 users not shown) | |||
Line 1: | Line 1: | ||
+ | === Background === | ||
+ | |||
+ | My honours project required me to develop some software to run, analyze, and subjectively test compression algorithms on motion capture data. Originally I developed an OpenGL application in Python, since Python is easier to work with than C, but didn't make much use of the object-oriented features of Python, because python is traditionally used procedurally, and because OpenGL relies on procedural code also. | ||
+ | |||
+ | My goal was to convert this program to a more fully object-oriented python program for easier modification and extensibility. | ||
+ | |||
+ | ==== Starting Point ==== | ||
+ | |||
+ | The original program featured a 3D scene displaying a motion. The user could press tab to cycle through the joints of the animated figure, and graphs of the motion for that joint would be displayed below. | ||
+ | |||
+ | Here's the original class design for that program: | ||
+ | |||
+ | [[Image:Rle41_original_design.jpg]] | ||
+ | |||
+ | I refactored this functionality into an OO design, and then added a subjective test feature. For this feature, two 3D scenes are shown simultaneously, along with quiz instructions, and users are asked to guess which of the scenes shows a compressed figure, and which shows the original. | ||
+ | |||
+ | The final class diagram looked like this. | ||
+ | |||
+ | [[Image:Rle41_final_diagram.jpg]] | ||
+ | |||
+ | === Notes on overall structure === | ||
+ | |||
+ | While the diagram looks superficially messy, the design is actually fairly clean. | ||
+ | |||
+ | Bear in mind that AnalysisWindow and QuizWindow are not used at the same time, and are effectively two separate programs. Consider also that a parser must inevitably work with many different classes, as its job is to create the various graphical objects displayed. | ||
+ | |||
+ | Apart from this, each individual class has the "feel" of having an intuitive position and role to play. I think the design has been able to [[model the real world]] well, and one can look at a class name and relationships and fairly quickly gain a good appreciation of what it does. | ||
+ | |||
+ | |||
=== Design Maxims Used === | === Design Maxims Used === | ||
− | [[Law of Demeter]] | + | ==== [[Law of Demeter]] ==== |
I made the Law of Demeter a big focus in this final redesign. I've found this to be a particularly helpful guide in structuring my code. | I made the Law of Demeter a big focus in this final redesign. I've found this to be a particularly helpful guide in structuring my code. | ||
Line 12: | Line 41: | ||
− | [[Tell, don't ask]] | + | |
+ | ==== [[Tell, don't ask]] ==== | ||
In earlier designs, the Window code would set up the size it required for each of its viewports. I refactored the code to be more in accordance with this maxim by giving each viewport a default size, and writing a rudimentary layout manager to change or override sizes if required. | In earlier designs, the Window code would set up the size it required for each of its viewports. I refactored the code to be more in accordance with this maxim by giving each viewport a default size, and writing a rudimentary layout manager to change or override sizes if required. | ||
Line 21: | Line 51: | ||
+ | ==== [[Design by contract]] ==== | ||
+ | When setting up the GlutApplication, there are various timing dependencies between function calls made by each. The important methods here list preconditions and postconditions for successful initialization of the Glut system and context. | ||
− | [[Observer]] | + | |
+ | |||
+ | ==== [[Observer]] ==== | ||
In this program, viewports have to work quite closely together. For instance, a GraphViewport needs to update every time the animation frame changes or the highlighted joint changes, so that it can update the Graph display appropriately. | In this program, viewports have to work quite closely together. For instance, a GraphViewport needs to update every time the animation frame changes or the highlighted joint changes, so that it can update the Graph display appropriately. | ||
In my initial design, the constructor for a FigureViewport had a parameter whereby one would pass in the GraphViewports that needed notifying. This seemed unclean as it resulted in unnecessary coupling, so I refactored the code such that arbitary observers could be attached to a Figure Viewport. Any object that registered as an observer of a FigureViewport merely needed to implement one method (effectively implementing an interface, although Python doesn't have explicitly defined interfaces). | In my initial design, the constructor for a FigureViewport had a parameter whereby one would pass in the GraphViewports that needed notifying. This seemed unclean as it resulted in unnecessary coupling, so I refactored the code such that arbitary observers could be attached to a Figure Viewport. Any object that registered as an observer of a FigureViewport merely needed to implement one method (effectively implementing an interface, although Python doesn't have explicitly defined interfaces). | ||
+ | |||
+ | I "outsourced" the logic for managing the notifications into separate Notifier classes to avoid clutter in the already rather burdened AnimatedFigure class. | ||
The resulted in greater extensibility for the Quiz program modification. The quiz layout features no graphs, and thus there are no listeners for the Figure Viewports in this situation. Instead of having to worry about passing in a null parameter or similar, I was able to completely ignore the listener functionality, which felt much cleaner and simpler. | The resulted in greater extensibility for the Quiz program modification. The quiz layout features no graphs, and thus there are no listeners for the Figure Viewports in this situation. Instead of having to worry about passing in a null parameter or similar, I was able to completely ignore the listener functionality, which felt much cleaner and simpler. | ||
Line 33: | Line 69: | ||
+ | ==== [[Composite]] ==== | ||
+ | The hierarchical joint structure of an animated figure is well suited to the composite pattern, since we have Joints which contain other Joints. Thus we have a Joint superclass with a primitive subclass (LeafJoint) and a composite subclass (InternalJoint). There is also a subclass of InternalJoint, RootJoint to add extra functionality to the first joint. For instance, a RootJoint has positional information whereas other joints have only angular orientation relative to their parent joints. | ||
− | |||
+ | ==== [[Template Method]] ==== | ||
+ | Joints are drawn in the same way, but InternalJoints and RootJoints must define extra code before and after drawing. Ideally, we shouldn't have to redefine the basic drawing code in each subclass. The the display() method of Joint is not subclassed, but two associated methods, predisplay() and postdisplay(), are. Note that the restriction on subclassing display is merely my own convention. There is no provision in Python for defining access restrictions. | ||
+ | |||
+ | |||
+ | |||
+ | ==== [[Visitor]] / [[Strategy]] ==== | ||
+ | |||
+ | The joint objects remain relatively fixed, but the addition of an approximation algorithm requires new operations to be defined over the hierarchy. Adding this code directly to the AnimatedFigure class adds clutter, particularly for complex algorithms such as point elimination based on endpoint differences. | ||
+ | |||
+ | Separating these algorithms into their own classes makes the code much cleaner, and makes it much more straightforward to add additional algorithms. Note that only one algorithm exists in the diagram and supplied source code at this stage. | ||
+ | |||
+ | Python's first-class functions mean that the strategy pattern is overkill by itself (an algorithm can be changed dynamically simply by storing a function in a variable, and modifying that variable as required, so separate objects are not necessary) but the different visitor objects for each algorithm are effectively strategy implementations also. | ||
+ | |||
+ | The visitor pattern is inevitably a trade-off between flexibility of the visitors and "visitees" (subjects). Here it is clear that flexibility of algorithm implementation is more useful, so the visitor pattern is clearly of benefit. | ||
+ | |||
+ | Currently only one compression algorithm is completed. Be warned that running this algorithm as the program is currently configured for is EXTREMELY slow (the output is saved so the algorithm need only be run once, and no effort has been put into optimization of the algorithm running time at this stage). | ||
+ | |||
+ | I've found a balance between the visitor pattern and encapsulation by placing the specific, high-level details of the algorithm in the visitor, while leaving the lower level calculation code in the original class. This keeps the details of the original class more abstracted, and allows potential reuse of the same low-level calculation methods by other implementations. | ||
Line 62: | Line 117: | ||
It made sense to make Keyboard static as there is only one keyboard. | It made sense to make Keyboard static as there is only one keyboard. | ||
+ | ==== No Flexibility Cost ==== | ||
+ | The current wrapping hits the sweet spot between the extremes of abstraction and familiarity/flexibility. No flexibility is lost because the original API is still available and used for drawing operations, and setup classes can be readily modified. Yet the level of abstraction is such that subsequent users of this code can write an OpenGL application by simply defining the drawing of each object, and won't need to waste any time on configuration unless necessary. They can also intuitively partition their scene into intuitive objects. | ||
+ | === Accomodations for Python Language Style === | ||
+ | Python has some rough edges for its OO support. Properties are simple to define (this syntax is PropertyName = property(getter_method_name, setter_method_name), and really help tidy up one's code, but due to a bug/flaw the getters and setters cannot be subclassed unless one explicitly redefines the property in the subclass. Hopefully this is fixed in a later version. | ||
+ | There was also a bug with default parameter values: If default parameters are defined both in a superclass and a subclass, then the superclasses default values take precedence. Possibly I am misunderstanding something but in any case, the values can fairly easily be set explicitly in the constructor whenever one runs into this problem. | ||
+ | Python is not a pure OO language, so I considered it overkill to make objects for absolutely everything. Python relies heavily on lists. I did not think it sensible to wrap a collection object around these lists, since they are a well-established and tested language concept. Hence I have retained lists (and occasionally tuples) as the primary means of storing collections. | ||
+ | In particular, there are no 3D primitive objects. This is partially because a 3-item list/tuple serves perfectly adequately, and also because the focus of this program involves splitting 3D points and angles into separate axes, regarding each axis largely separately. Thus a Point class (or similar) was not warranted. | ||
− | === | + | === Conclusion === |
− | Python | + | While the refactoring time was considerable, particularly as I am still rather a Python novice, I think porting this to OO was very valuable. I have not fully added the compression algorithms to the strategy pattern as yet, but the way this functionality is implemented means I will be able to easily add and experiment with many different data elimination/compression techniques. |
− | + | The biggest drawback of this OO version is with performance. The Python wrapper to the (native C language) OpenGL library degrades performance somewhat, and adding OO adds more overhead still. Fortunately my application is not graphically demanding. However, Python allows for parts of one's code to be rewritten in C, so if this code was to be reused to an intensive application, the code responsible for speed bottlenecks could be converted as required. | |
− | + | While the OO conversion has certainly resulted in higher quality, more manageable code, it was more difficult to program at the time, because one must be much more careful to be semantically correct. While in functional program you might simply write and call a "display_caption()" function, in the OO model, one must consider which object the caption strictly belongs to, how it should be encapsulated or exposed, and other subtle design issues. There is certainly a delayed payoff with OO, but the bigger or more complex the project, the greater this payoff is. | |
+ | |||
+ | |||
+ | === Source === | ||
− | + | [[Image:Oodesignstudy.zip]] |
Latest revision as of 22:21, 5 July 2010
Contents |
Background
My honours project required me to develop some software to run, analyze, and subjectively test compression algorithms on motion capture data. Originally I developed an OpenGL application in Python, since Python is easier to work with than C, but didn't make much use of the object-oriented features of Python, because python is traditionally used procedurally, and because OpenGL relies on procedural code also.
My goal was to convert this program to a more fully object-oriented python program for easier modification and extensibility.
Starting Point
The original program featured a 3D scene displaying a motion. The user could press tab to cycle through the joints of the animated figure, and graphs of the motion for that joint would be displayed below.
Here's the original class design for that program:
I refactored this functionality into an OO design, and then added a subjective test feature. For this feature, two 3D scenes are shown simultaneously, along with quiz instructions, and users are asked to guess which of the scenes shows a compressed figure, and which shows the original.
The final class diagram looked like this.
Notes on overall structure
While the diagram looks superficially messy, the design is actually fairly clean.
Bear in mind that AnalysisWindow and QuizWindow are not used at the same time, and are effectively two separate programs. Consider also that a parser must inevitably work with many different classes, as its job is to create the various graphical objects displayed.
Apart from this, each individual class has the "feel" of having an intuitive position and role to play. I think the design has been able to model the real world well, and one can look at a class name and relationships and fairly quickly gain a good appreciation of what it does.
Design Maxims Used
Law of Demeter
I made the Law of Demeter a big focus in this final redesign. I've found this to be a particularly helpful guide in structuring my code.
In particular, following the Law of Demeter stops one from "cheating" with one's interface definition. In early designs, I created a nice tidy interface for a class, but would do all the dirty work associated with that class by grabbing an object "through" the interface and then manipulating it. Obviously, this is counter-productive, and result in unnecessary coupling. The apparently "clean" interface only results in more dirt elsewhere.
Following the Law does add to the size of your interface, and results in some cascading getter methods, but provides a more accurate picture of the realities of what one is exposing. It also encourages the programmer to expose only the exact data necessary, rather than whole objects. This is good for reducing coupling.
Tell, don't ask
In earlier designs, the Window code would set up the size it required for each of its viewports. I refactored the code to be more in accordance with this maxim by giving each viewport a default size, and writing a rudimentary layout manager to change or override sizes if required.
This change made the code much more extensible. After this change I wrote a subclass of Window called QuizWindow, which runs a user evaluation program. I was able to change the program from a single figure view with graphs, to a double figure view with quiz instructions, very simply and intuitively.
I simply constructed two FigureViewport objects, wrote a small QuizMaster object to run the quiz, along with a QuizViewport to display instructions, and it Just Worked. Because I adopted the Tell don't Ask philosophy, I could just tell the program to make me two FigureViewports, rather than having to faff about specifying their sizes manually.
Design by contract
When setting up the GlutApplication, there are various timing dependencies between function calls made by each. The important methods here list preconditions and postconditions for successful initialization of the Glut system and context.
Observer
In this program, viewports have to work quite closely together. For instance, a GraphViewport needs to update every time the animation frame changes or the highlighted joint changes, so that it can update the Graph display appropriately.
In my initial design, the constructor for a FigureViewport had a parameter whereby one would pass in the GraphViewports that needed notifying. This seemed unclean as it resulted in unnecessary coupling, so I refactored the code such that arbitary observers could be attached to a Figure Viewport. Any object that registered as an observer of a FigureViewport merely needed to implement one method (effectively implementing an interface, although Python doesn't have explicitly defined interfaces).
I "outsourced" the logic for managing the notifications into separate Notifier classes to avoid clutter in the already rather burdened AnimatedFigure class.
The resulted in greater extensibility for the Quiz program modification. The quiz layout features no graphs, and thus there are no listeners for the Figure Viewports in this situation. Instead of having to worry about passing in a null parameter or similar, I was able to completely ignore the listener functionality, which felt much cleaner and simpler.
Composite
The hierarchical joint structure of an animated figure is well suited to the composite pattern, since we have Joints which contain other Joints. Thus we have a Joint superclass with a primitive subclass (LeafJoint) and a composite subclass (InternalJoint). There is also a subclass of InternalJoint, RootJoint to add extra functionality to the first joint. For instance, a RootJoint has positional information whereas other joints have only angular orientation relative to their parent joints.
Template Method
Joints are drawn in the same way, but InternalJoints and RootJoints must define extra code before and after drawing. Ideally, we shouldn't have to redefine the basic drawing code in each subclass. The the display() method of Joint is not subclassed, but two associated methods, predisplay() and postdisplay(), are. Note that the restriction on subclassing display is merely my own convention. There is no provision in Python for defining access restrictions.
Visitor / Strategy
The joint objects remain relatively fixed, but the addition of an approximation algorithm requires new operations to be defined over the hierarchy. Adding this code directly to the AnimatedFigure class adds clutter, particularly for complex algorithms such as point elimination based on endpoint differences.
Separating these algorithms into their own classes makes the code much cleaner, and makes it much more straightforward to add additional algorithms. Note that only one algorithm exists in the diagram and supplied source code at this stage.
Python's first-class functions mean that the strategy pattern is overkill by itself (an algorithm can be changed dynamically simply by storing a function in a variable, and modifying that variable as required, so separate objects are not necessary) but the different visitor objects for each algorithm are effectively strategy implementations also.
The visitor pattern is inevitably a trade-off between flexibility of the visitors and "visitees" (subjects). Here it is clear that flexibility of algorithm implementation is more useful, so the visitor pattern is clearly of benefit.
Currently only one compression algorithm is completed. Be warned that running this algorithm as the program is currently configured for is EXTREMELY slow (the output is saved so the algorithm need only be run once, and no effort has been put into optimization of the algorithm running time at this stage).
I've found a balance between the visitor pattern and encapsulation by placing the specific, high-level details of the algorithm in the visitor, while leaving the lower level calculation code in the original class. This keeps the details of the original class more abstracted, and allows potential reuse of the same low-level calculation methods by other implementations.
OpenGL Abstraction
At the low level, the program still relies on the functional OpenGL library calls. I saw little use for a Graphics object (eg graphics.draw_point(x,y) ), which would require additional learning effort from programmers with little resulting benefit. Instead, I focussed on abstracting the less relevant parts of the OpenGL library such that the difficult details of the OpenGL system are largely hidden. The hope is to allow the developer to create a graphics application more easily by dividing the graphics creation workload into the implementation of a display method for each program object.
Complex graphics can then be built up from a number of smaller primitive objects. Also, because each object rendered is an actual program object, integration with object-oriented application logic is far more seamless.
Boilerplate
GlutMachine and GlutApplication provide an abstraction for the mandatory parts (the "Hello World" parts) of an OpenGL application. Dividing the boilerplate into two classes means that the largely fixed parts of the boilerplate (for instance, the call to glutMainLoop() ) are kept separate from the more configurable parts of the boilerplate (such as the glClear call, which can take various parameters).
The existing implementation of GlutApplication is quite straightforward, allowing easy extension through modification or subclassing if a multi-windowing, additional library calls, or other complexity is required.
Event Handling
Another component of the OpenGL library I have abstracted away is the Keyboard event handling. Instead of writing a large switch statement to deal with keypresses, any object can simply wire up event handlers by calling a static method. For instance, a FigureViewport can have its next_joint method called when the tab key is pressed simply by adding the following line:
Keyboard.attach_key_listener("\t", self.next_joint)
It made sense to make Keyboard static as there is only one keyboard.
No Flexibility Cost
The current wrapping hits the sweet spot between the extremes of abstraction and familiarity/flexibility. No flexibility is lost because the original API is still available and used for drawing operations, and setup classes can be readily modified. Yet the level of abstraction is such that subsequent users of this code can write an OpenGL application by simply defining the drawing of each object, and won't need to waste any time on configuration unless necessary. They can also intuitively partition their scene into intuitive objects.
Accomodations for Python Language Style
Python has some rough edges for its OO support. Properties are simple to define (this syntax is PropertyName = property(getter_method_name, setter_method_name), and really help tidy up one's code, but due to a bug/flaw the getters and setters cannot be subclassed unless one explicitly redefines the property in the subclass. Hopefully this is fixed in a later version.
There was also a bug with default parameter values: If default parameters are defined both in a superclass and a subclass, then the superclasses default values take precedence. Possibly I am misunderstanding something but in any case, the values can fairly easily be set explicitly in the constructor whenever one runs into this problem.
Python is not a pure OO language, so I considered it overkill to make objects for absolutely everything. Python relies heavily on lists. I did not think it sensible to wrap a collection object around these lists, since they are a well-established and tested language concept. Hence I have retained lists (and occasionally tuples) as the primary means of storing collections.
In particular, there are no 3D primitive objects. This is partially because a 3-item list/tuple serves perfectly adequately, and also because the focus of this program involves splitting 3D points and angles into separate axes, regarding each axis largely separately. Thus a Point class (or similar) was not warranted.
Conclusion
While the refactoring time was considerable, particularly as I am still rather a Python novice, I think porting this to OO was very valuable. I have not fully added the compression algorithms to the strategy pattern as yet, but the way this functionality is implemented means I will be able to easily add and experiment with many different data elimination/compression techniques.
The biggest drawback of this OO version is with performance. The Python wrapper to the (native C language) OpenGL library degrades performance somewhat, and adding OO adds more overhead still. Fortunately my application is not graphically demanding. However, Python allows for parts of one's code to be rewritten in C, so if this code was to be reused to an intensive application, the code responsible for speed bottlenecks could be converted as required.
While the OO conversion has certainly resulted in higher quality, more manageable code, it was more difficult to program at the time, because one must be much more careful to be semantically correct. While in functional program you might simply write and call a "display_caption()" function, in the OO model, one must consider which object the caption strictly belongs to, how it should be encapsulated or exposed, and other subtle design issues. There is certainly a delayed payoff with OO, but the bigger or more complex the project, the greater this payoff is.