This article is about a class I wrote a few years ago for use in an image acquisition framework I was working on. It was interesting to revisit it and see if my thinking at the time still makes sense, if I thought it was still a good design, and to see how it might change with C++ 11.
Over a period of two years or so, I had occasion to work with a variety of image acquisition devices, including GigE Standard compliant cameras, CameraLink and RS-449 interfaces, and analog devices. Every device was different in terms of features and configurability, and each vendor had completely different API’s. My feeling was that any software that my colleagues or I wrote should be able to interact with all image acquisition devices in the same way, and that we should have access to a library of common image processing functions and a common execution framework.
So I started working on a set of abstractions for handling various aspects of the “image acquisition domain”. This ended up not being as easy as it sounds, at least not for me, but I eventually did find a set of interfaces and data types that I felt good about. One essential piece was a data type for managing image data in the large distributed system environment in which I worked. I called it ImageBlock, to indicate that this wasn’t intended to be a full-blown image type, but rather a way to manage image data memory allocations, transfer image data between various entities, and decouple the data from its source. The design did not make provisions in its public interface for any image processing functionality.
Its hard to generalize with only one example, so the following requirements evolved over time as more examples were added to the “framework”:
- Gray-scale image data only
- Large image sizes, up to 4K by 4K, 1 to 16 bits per pixel
- No or lossless compression
- Can be passed around by value
- Can be serialized for storage and/or transmission across Ethernet
- Thread-safe
- Memory-safe (no one wants 16 million byte data chunks floating about loose)
- Independent of any image source hardware
- Handles different types of image memory allocation schemes
- Manages image data efficiently
- Allow intuitive boolean test (e.g., “if ( !image ) <do something>”
- Allow for easy extension
Requirements 1-3 were dictated by the nature of the work (laser experiments) and the customers (scientists who like LOTS of data, but don’t like software returning any of their valuable image data to the ether). I ginned the rest of them up to make my life more interesting, fulfilling, and in the long run simpler.
The Header
class ImageBlock { private: // // Part of the safe bool idiom. // typedef void (ImageBlock::*bool_type)() const; void this_type_does_not_support_comparisons() const { /* does nothing */ } public: struct NullDeleter { void operator()(unsigned char* imageData) { /* does nothing */ } }; typedef std::map<std::string, std::string> ImageContext; ImageBlock(); ImageBlock(int width, int height, int depth); ImageBlock(int width, int height, int depth, unsigned char* data); template <Deleter> ImageBlock(int width, int height, int depth, unsigned char* data, Deleter deleter) : width_(width), height_(height), depth_(depth), bytesPerPixel_((depth+7)/8), size_(width*height*((depth+7)/8)), data_(data, deleter) { } ~ImageBlock(); static ImageBlock createFromData(int width, int height, int depth, const unsigned char* data); ImageBlock clone() const; int width() const; int height() const; int depth() const; int size() const; int bytesPerPixel() const; int numberOfPixels() const; const unsigned char* data() const; operator bool_type() const; private: int width_; int height_; int depth_; int bytesPerPixel_; unsigned int size_; boost::shared_array data_; };
Construction
The first constructor makes a “null” ImageBlock; both dimensions are zero, and it points to no data. See Use Semantics below for more information.
The second constructor makes an ImageBlock with memory of width X height, but doesn’t initialize the memory.
The third constructor creates an ImageBlock with a chunk of memory width X height, and creates a shared_array object that points at data. This constructor does not make a deep copy of the data.
The fourth constructor is the same as the third, with the addition of a deleter, used by the shared_array when it decides to delete the memory (or not, see below for more).
The static method createFromData does a deep copy of data.
Memory Issues
Some applications use very large images, as big as 2K by 2K, 12 bits per pixel in some cases. I wanted to be able to pass ImageBlock instances by value, and obviously didn’t want to incur the cost of copying that much data. I used Boost.SharedArray to provide implicit sharing of the memory among 2 or more instances of an ImageBlock. When an ImageBlock is assigned or copy constructed, the shared array is copied, which increases the reference count by 1. Both instances of ImageBlock point to the same data. The clone() method can be used to do a deep copy of the ImageBlock, including the image data, which makes an ImageBlock instance with its own data. The interface provides only const access to the image data, so any ImageBlock instances that share that data know that the data will remain unchanged.
As I added more types of interfaces and drivers to my design, I found several different memory management modes. Some APIs expect the programmer to allocate memory for an image and pass the pointer, while others allocate their own memory buffers, ownership of which remain with the API. Some API’s have their own allocation/deallocation methods, and expect the programmer to use those methods. I needed a way to handle all these methods in a uniform, transparent way. Boost.SharedArray offered the solution in the form of a deleter, which is a functor (see NullDeleter in the header) that is invoked when the use count goes to zero. If ownership of the memory stays with the API, the NullDeleter is provided to the constructor by the image source. If a special method needs to be used, a functor that invokes that special method is provided. If the programmer owns the memory, the shared pointer works as usual.
Access to the underlying image data is provided by the data() method, which returns a constant pointer to the data. Should a programmer want to manipulate the data in the ImageBlock, they will have to copy it out, make their changes, and then create a new ImageBlock (if that makes sense, such as in an imaging pipeline that performs some set of image altering processes before passing the new image along).
There is no information about endian-ness in the interface, and the data is stored as a contiguous series of unsigned char. This makes it simple to deal with the data. If you are passing the image around on the same processor, the order that the imaging device provided is (for all the examples I worked with) the correct endian-ness such that you can cast the data as unsigned short. If the image is shipped over a network interface to a computer with a different architecture (as I had to do), I just included the endian-ness as part of the overall message so that the receiver would know. Many times the images were 8-bit per pixel, so endian-ness wasn’t even an issue.
Use Semantics
ImageBlock can be used as a value object, ala Domain Driven Design by Eric Evans. ImageBlock is copyable via the default copy constructor and default assignment operator, and an instance doesn’t have any particular identity; a given method or function that takes an ImageBlock parameter by value or reference only cares that it has an ImageBlock, not any particular ImageBlock. ImageBlock instances can be discarded after passing by value to a method, or after being returned from a method, for instance. Most importantly, ImageBlock is immutable; its contents cannot be changed via the public interface. The implication is that ImageBlock is thread-safe sans locking.
Requirement 11 was met by use of the Safe Bool Idiom. With C++ 11, this idiom is no longer necessary; one can now use explicit operator bool() const to easily accomplish the same thing. This lets a programmer easily check if an ImageBlock is valid, and use an invalid ImageBlock as a return value from a method or function. An invalid ImageBlock has zero length dimensions and no data. Like so:
ImageBlock ib = camera.acquireImage(); if ( !ib ) { log("Image acquisition failed"); throw ImageAcquisitionError; }
Extensibility
The ImageBlock class adheres to the Open-Closed Principle. Its interface is about as minimal as I could make it. Anything a programmer would need is in the interface. Instead of adding methods to the ImageBlock to make use of it, I wrote stand alone functions that accept ImageBlock (or an array of ImageBlock) as a parameter. Here is a simple example:
void writeImageBlockRaw(const std::string& path, const ImageBlock& ib) { FILE* fptr = fopen(path.c_str(), "w"); if ( fptr ) { for ( int i=0; i<ib.numberOfPixels(); i++ ) { fprintf(fptr, "%d, ", ib[i]); } } fclose(fptr); }
This function is useful for debugging. The resulting file can be read in by Excel or Matlab and visualized or analyzed in other ways. Other functions are writeImageBlockToJPEG, writeImageBlockToTIFF, etc. and findFeatureX. There is no need for these to be methods on the ImageBlock class. A huge number of functions, in different namespaces, can be created without touching the class (which is why it doesn’t have a virtual destructor, I don’t want anyone to inherit from it). So the class does a good job of meeting the Open-Closed Principle.
Conclusion
I still think this class is useful, and a decent design given the environment I was in and the requirements I was working against. If I port this class to C++ 11, there would be a number of changes/improvements:
- No shared_array, use shared_ptr
- Use final keyword
- Move constructor
- Use explicit operator bool() const instead of Safe Bool Idiom
In retrospect, the null ImageBlock was probably not a great idea. The data() method returns a null pointer in that case, which could cause all kinds of problems and confusion. Maybe I should throw and exception? Not sure. The second constructor is a bit silly; a programmer can’t access the memory to initialize it. That constructor should probably be
ImageBlock(int width, int height, int depth, pixelValue);
This would be useful for debugging, and probably not much else.