9
\$\begingroup\$

Task

I wrote a movie barcode generator in C++ using OpenCV. Self-described engineer, space lover and tea drinker Thomas Poulet defines a movie barcode as follows:

A Movie Barcode is the color identity card of the movie. Basically for each frame you take the dominant color and create a stripe out of it. The result is an overview of the overall mood of the movie.

My program uses the following template:

./barcode movieFile samplingRate nBatches nWorkers [-p] [-l]

Here are definitions for the required arguments and flags:

  • movieFile: a relative path to a video file (e.g., movie.mp4)
  • samplingRate: the rate at which frames are retrieved
  • nBatches: the number of batches (a collection of frames) used in the barcode
  • nWorkers: the number of workers used to process batches
  • p: this flag indicates whether to apply a polar transformation to the barcode
  • l: this flag indicates whether to log each step

Approach

Conceptually, my approach uses two key ingredients: a Reader and Worker Class in alignment with the Producer/Consumer design pattern. The reader is responsible for reading in frames and the workers process a batch of frames into a strip.

Overview of Reader and Worker Class

Code

I wrote this program in a single main.cpp file. The source code is shared below:

/*
 * main.cpp
 * --------
 * This program takes in a video file (e.g., .mp3 or .mp4) and yields a movie barcode
 * with the desired sample rate and batches. The terminal command uses the following template:
 * ./barcode movieFile samplingRate nBatches nWorkers [-p] [-l]
*/

// Include Header Files
#include <opencv2/opencv.hpp>
#include <getopt.h>
#include <iostream>
#include <cassert>
#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>

/*
 * Logger Class
 * ------------
 * This class provides an interface to log messages (e.g., Worker Initiated)
 * By default logging is disabled but may be enabled via the -l flag.
*/
class Logger {
public:
    static bool enableLogging;
    static void log(const std::string &message) {
        if (enableLogging) {
            std::cout << message << std::endl;
        }
    }
};
bool Logger::enableLogging = false;

/*
 * checkArguments()
 * ----------------
 * This helper functions returns TRUE is the arguments supplied appear valid and false otherwise.
 * Errors are reported via assertion statements.
*/
void checkArguments(const std::string &movieFile, int &samplingRate, int &nBatches, int &nWorkers) {
    // Assert that the movie file is readable
    cv::VideoCapture cap(movieFile);
    assert(cap.isOpened());

    // Assert that the arguments are non-negative and do not exceed extreme bounds
    int totalFrames = (int)cap.get(cv::CAP_PROP_FRAME_COUNT);
    int usedFrames = totalFrames / samplingRate;
    assert(samplingRate > 0);
    assert(nBatches > 0);
    assert(nWorkers > 0);
    assert(samplingRate < totalFrames);
    assert(nBatches < usedFrames);
}

/*
 * polarTransform()
 * ----------------
 * This helper function remaps the barcode (passed by reference) from cartesian to 
 * polar space in-place.
*/
void polarTransform(cv::Mat &barcode, int nBatches) {
    // Prepare for transformation
    cv::Mat flipped;
    cv::Mat polarImage;
    cv::rotate(barcode, flipped, cv::ROTATE_90_CLOCKWISE);
    
    // Apply transformation
    cv::Size dsize(nBatches, nBatches);
    cv::Point2f center(nBatches / 2.0f, nBatches / 2.0f);
    int maxRadius = nBatches / 2.0f;
    cv::warpPolar(flipped, polarImage, dsize, center, maxRadius, cv::WARP_INVERSE_MAP);

    // Create a circular mask to make pixels outside the circle transparent
    cv::Mat mask = cv::Mat::zeros(polarImage.size(), CV_8UC1);
    cv::circle(mask, center, maxRadius, cv::Scalar(255), -1);

    // Apply the mask to make pixels outside the circle transparent
    cv::cvtColor(polarImage, polarImage, cv::COLOR_BGR2BGRA);
    polarImage.setTo(cv::Scalar(0, 0, 0, 0), mask == 0);
    barcode = polarImage;
}

/*
 * Batch Struct
 * ------------
 * The Batch Struct maintains a vector of frames and a unique identifier.
*/
struct Batch {
    int id;
    std::vector<cv::Mat> frames;
};

/*
 * Worker Class
 * ------------
 * This class maintains all worker threads. It includes the following functions:
 * processBatch() - reads in batches until the worker queue is empty and reader is finished
 * addBatch() - pushes a batch to the worker queue
 * getResults() - returns `results` vector containing row-reduced batches
 * setState() - sets reader state
*/
class Worker {
public:
    // Static Member Variables
    static std::vector<cv::Mat> results;
    static std::vector<Worker*> workers;
    static std::queue<Batch> queue;
    static std::mutex mtx;
    static std::condition_variable cv;
    static bool doneReading;

    // Non-Static Member Variables
    int workerId;
    std::thread workerThread;

    // Constructor
    Worker(int workerId) : workerId(workerId) {
        workerThread = std::thread(&Worker::processBatch, this);
        workers.push_back(this);
        Logger::log("Worker " + std::to_string(workerId) + " created.");
    }

    void processBatch() {
        while (true) {
            Batch batch;
            {   
                // Check if there are batches to process...
                std::unique_lock<std::mutex> lock(mtx);
                cv.wait(lock, [this] { return !queue.empty() || doneReading; });
                if (queue.empty() && doneReading) break;
                if (queue.empty()) continue;

                // If so, peek and pop off the queue
                batch = queue.front();
                queue.pop();
            }
            Logger::log("Worker " + std::to_string(workerId) + " processing batch " + std::to_string(batch.id) + ".");

            // Process the batch
            cv::Mat concatenation;
            cv::Mat average;
            cv::hconcat(batch.frames, concatenation);
            cv::reduce(concatenation, average, 1, cv::REDUCE_AVG);

            // Store the result
            // thread-safe because we never modify the same memory [https://stackoverflow.com/a/61013298]
            results[batch.id] = average;
            Logger::log("Worker " + std::to_string(workerId) + " finished processing batch " + std::to_string(batch.id) + ".");
        }
        Logger::log("Worker " + std::to_string(workerId) + " exiting.");
    }

    static std::vector<cv::Mat> getResults() {
        return results;
    }

    static std::vector<Worker*>& getWorkers() {
        return workers;
    }

    static void allocateBatch(const Batch &batch) {
        {
            std::unique_lock<std::mutex> lock(mtx);
            queue.push(batch);
        }
        cv.notify_one();
    }

    static void setState(bool state) {
        doneReading = state;
        cv.notify_all();
    }
};

// Define Static Member Variables
std::vector<cv::Mat> Worker::results;
std::vector<Worker*> Worker::workers;
std::queue<Batch> Worker::queue;
std::mutex Worker::mtx;
std::condition_variable Worker::cv;
bool Worker::doneReading = false;

/*
 * Reader Class
 * ------------
 * This (singleton) class maintains a reader instance which reads frames from a movie.  
 * It includes the following functions:
 * readFrames() - grabs, retrieves, and allocates batches to workers
 * allocateBatch() - allocates batch to workers in round-robin fashion
*/
class Reader {
public:
    // Instance Member Variables
    int samplingRate;
    int nBatches;
    int nWorkers;
    int framesPerBatch;
    int batchSplit;
    int currentWorker;
    std::vector<Worker*>& workers;
    cv::VideoCapture cap;
    
    Reader(const std::string &movieFile, int samplingRate, int nBatches, int nWorkers)
        : samplingRate(samplingRate), nBatches(nBatches), nWorkers(nWorkers),
          currentWorker(0), workers(Worker::getWorkers()) {
            // Open the video file
            cap.open(movieFile);

            // Compute frames per batch
            int totalFrames = (int)cap.get(cv::CAP_PROP_FRAME_COUNT);
            int usedFrames = totalFrames / samplingRate;
            framesPerBatch = usedFrames / nBatches;
            Logger::log("Reader created for file: " + movieFile);
    }

    void readFrames() {
        // Setup Counters and Current Batch
        int frameCounter = 0;
        int batchCounter = 0;
        Batch currentBatch;
        currentBatch.id = batchCounter;

        while (batchCounter < nBatches) {
            // Grab frames from the capture
            cap.grab();

            // Retrieve frames based on the sampling rate
            if (frameCounter % samplingRate == 0) {
                cv::Mat frame;
                cap.retrieve(frame);
                currentBatch.frames.push_back(frame);

                // Add frame and allocate batch (if size is met) 
                if (currentBatch.frames.size() == framesPerBatch) {
                    Worker::allocateBatch(currentBatch);
                    batchCounter++;
                    currentBatch = Batch{batchCounter};
                }
            }
            frameCounter++;
        }
        // Handle remaining frames in the last batch
        if (!currentBatch.frames.empty()) {
            Worker::allocateBatch(currentBatch);
        }
        Worker::setState(true);
        Logger::log("Finished reading frames.");
    }
};

int main(int argc, char** argv) {
    // Initialize variables
    std::string movieFile;
    int samplingRate = 0;
    int nBatches = 0;
    int nWorkers = 0;
    bool applyPolar = false;

    // Parse Arguments using getopt
    int opt;
    while ((opt = getopt(argc, argv, "pl")) != -1) {
        switch (opt) {
            case 'p':
                applyPolar = true;
                break;
            case 'l':
                Logger::enableLogging = true;
                break;
            default:
                std::cerr << "Usage: " << argv[0] << " movieFile samplingRate nBatches nWorkers [-p] [-l]" << std::endl;
                return EXIT_FAILURE;
        }
    }
    std::cerr << applyPolar << std::endl;

    // Parse positional arguments
    if (optind + 4 > argc) {
        std::cerr << "Usage: " << argv[0] << " movieFile samplingRate nBatches nWorkers [-p] [-l]" << std::endl;
        return EXIT_FAILURE;
    }
    movieFile = argv[optind];
    samplingRate = std::stoi(argv[optind + 1]);
    nBatches = std::stoi(argv[optind + 2]);
    nWorkers = std::stoi(argv[optind + 3]);

    // Check Arguments
    checkArguments(movieFile, samplingRate, nBatches, nWorkers);

    // Setup workers
    Worker::results.resize(nBatches);
    for (int id = 0; id < nWorkers; ++id) {
        new Worker(id);
    }

    // Construct and initiate reader
    Reader reader(movieFile, samplingRate, nBatches, nWorkers);
    reader.readFrames();

    // Wait for all workers to finish up their batches
    auto &workers = Worker::getWorkers();
    for (auto &worker : workers) {
        if (worker->workerThread.joinable()) {
            worker->workerThread.join();
        }
    }

    // Package the vector into a single image
    std::vector<cv::Mat> input = Worker::getResults();
    cv::Mat barcode;
    cv::hconcat(input, barcode);
    
    // Apply polar transform is requested...
    if (applyPolar) {
        polarTransform(barcode, nBatches);
    }
    
    // Save the barcode image
    cv::imwrite("../barcode.png", barcode);
    Logger::log("Barcode image saved as barcode.png");

    // Exit with Status 0
    return 0;
}

Example Output

Here is an example using a compilation of cutscenes from Zelda Breath of the Wild. In post, a filter was applied to brighten colors and squoosh was used to compress the image to a reasonable size.

Movie Barcode using Legend of Zelda Cut Scenes

Concerns

Anything is fair game, but I wanted to draw attention to the following points:

  1. Does the code have good style in terms of commenting and modularity?
  2. Can the approach be improved to leverage multithreading more effectively?
\$\endgroup\$
7
  • 1
    \$\begingroup\$ "Anything is fair game" Respectfully: the premise that "result is an overview of the overall mood of the movie" is flawed. This disregards a trove of B&W classics, and the recent "Barbie" would likely be a solid band of pink... Choosing the right (or wrong) metric should be Paramount and Universal (sorry for the puns). \$\endgroup\$ Commented Jul 19, 2024 at 2:23
  • \$\begingroup\$ The description says “the dominant color”, but you compote the average over a batch of frames. The dominant color would likely be the modal color. The average tends to be brown or some other unsaturated, dull color, even if the frame is very bright and colorful. \$\endgroup\$ Commented Jul 19, 2024 at 2:58
  • \$\begingroup\$ @Fe2O3 I appreciate the puns! Agreed, the effectiveness of this process is debatable and likely depends on the choice of film. \$\endgroup\$ Commented Jul 19, 2024 at 4:41
  • \$\begingroup\$ @CrisLuengo Yes, admittedly I did not compute the dominant color but took a row-wise average over a batch of frames. This is because I wanted a rugged instead of a smooth feel to the barcode. To your point on the average being brown/dull, it is worth pointing out that the average color produced depends on the color space you operate in. For instance, if you averaged in HSV you might retain saturation. In retrospect, both of these would be straightforward to add as arguments! \$\endgroup\$ Commented Jul 19, 2024 at 6:34
  • 2
    \$\begingroup\$ You can't sort colors. And doing a histogram per channel will give you the modal R, G and B values, but their combination is likely not the modal color. The appropriate method is to cluster the colors, and use the centroid of the largest cluster. How many clusters to use is an open question. K-means is the standard clustering algorithm, but it is really slow. Here I show an alternative algorithm that works just as well and is much faster (in the comments is a link to a blog post that explains the algorithm in more detail). \$\endgroup\$ Commented Jul 19, 2024 at 19:47

1 Answer 1

6
\$\begingroup\$

Since @indi deleted their answer before I could work through the proposed changes and ultimately accept it, I'll note down their critiques (to the best of my memory) along with the corresponding fixes.

The Interface

The interface is not very pleasant. It demands that the user specify all sorts of information (e.g., the sampling rate) before proceeding. The only essential ingredient this recipe needs is the file. Everything else does not need to be specified. Instead, we should house options in a struct with reasonable defaults.

Further, the use of assert to check arguments is considered bad practice and should be avoided. The reason is because it aggressively handles the situation when much lighter alternatives (i.e., exceptions) exist. It is also semantically incorrect.

Poor Design

While the use of a Reader and Worker Class appear to add structure in alignment with the Consumer/Producer model, they ultimately go against that purpose. In particular, the classes are highly coupled making the code functionally untestable. It is also worth pointing out that the Reader is NOT a singleton class and blows up if readFrames() is called multiple times. Conceptually, it makes much more sense to make the Reader a function and rework the Worker into a ThreadPool.

Artifacts

There are several artifacts in the code that detract from its readability. In no particular order...

  • checkArguments does NOT return True/False
  • allocateBatch is NOT present in Reader
  • Several member variables (i.e., batchSplit) are unused
  • EXIT_FAILURE is never defined
  • etc.

All of these stem from previous versions of the code that were not properly cleaned before posting.

Comments

The comments suffer from three mains drawbacks:

  1. They are wrong
  2. They are not specific
  3. They are obvious and unnecessary

A particularly fun example...

// Check Arguments
checkArguments(movieFile, samplingRate, nBatches, nWorkers);

Logging

The logger is inefficient and creates multiples strings before actually printing out its message. For instance, consider the snippet below:

Logger::log("Worker " + std::to_string(workerId) + " created.");

Here, we create three strings before passing them to the logger.

Concurrency

The use of primitives like std::mutex or std::thread make the code harder to manage. Fundamentally, they implement a ConcurrentQueue so it is best to use that directly.

Improved Code

I address all of the issues above. The interface was simplified. Artifacts were removed. Comments are sparser. Logging is handled by an outside tool that is thread safe and blazingly fast. The Worker was overhauled in favor of a ThreadPool and the Reader is now a function.

/*
 * main.cpp
 * --------
 * This program takes as input a video file (e.g., example.mp4) and yields a movie barcode
 * with the desired specifications. The terminal command uses the following template:
 * ./barcode -f movie.mp4 -r 1 -b 5000 -w 5 -v -t
*/

#include <opencv2/opencv.hpp>
#include <boost/asio/thread_pool.hpp>
#include <boost/asio/post.hpp>
#include <vector>
#include <getopt.h>
#include <spdlog/spdlog.h>

#define SUCCESS true;
#define FAILURE false;

/*
 * Batch Struct
 * ------------
 * The Batch structure maintains a vector of frames and a unique identifier.
 * This abstractions helps process frames in easy-to-manage groups.
*/
struct Batch {
    int id;
    std::vector<cv::Mat> frames;
};

/*
 * CommandLineArguments Struct
 * ---------------------------
 * The CommandLineArguments structure maintains all barcode options.
 * Missing options are reasonably defaulted.
*/
struct CommandLineArguments {
    std::string file;
    int rate = 1;
    int batches = 100;
    int workers = 5;
    bool verbose = false;
    bool transform = false;
};

/*
 * printUsage()
 * ------------
 * This helper function displays all available barcode options. 
*/
void printUsage(const char* programName) {
    std::cout << "Usage: " << programName << " [options]\n"
              << "Options:\n"
              << "  -f, --file <file>          Movie file (required)\n"
              << "  -r, --rate <rate>          Sampling rate (default: 1)\n"
              << "  -b, --batches <batches>    Number of batches (default: 100)\n"
              << "  -w, --workers <workers>    Number of workers (default: 5)\n"
              << "  -v, --verbose              Verbose output (default: false)\n"
              << "  -t, --transform            Transform output (default: false)\n"
              << "  -h, --help                 Display this help message\n";
}

/*
 * parseArguments()
 * ----------------
 * This helper function parses the supplied command line arguments and updates the structure 
 * (passed by reference) defaults accordingly. Notably, it demands that a file is supplied.
*/
bool parseArguments(int argc, char* argv[], CommandLineArguments& arguments) {
    int opt;
    while ((opt = getopt(argc, argv, "f:r:b:w:vth")) != -1) {
        switch (opt) {
        case 'f':
            arguments.file = optarg;
            break;
        case 'r':
            arguments.rate = std::stoi(optarg);
            break;
        case 'b':
            arguments.batches = std::stoi(optarg);
            break;
        case 'w':
            arguments.workers = std::stoi(optarg);
            break;
        case 'v':
            arguments.verbose = true;
            break;
        case 't':
            arguments.transform = true;
            break;
        case 'h':
        default:
            printUsage(argv[0]);
            return FAILURE;
        }
    }

    // Check that a video file is supplied
    if (arguments.file.empty()) {
        spdlog::error("a movie file is required");
        printUsage(argv[0]);
        return FAILURE;
    }

    return SUCCESS;
}

/*
 * checkArguments()
 * ----------------
 * This helper functions returns TRUE is the arguments supplied appear valid and false otherwise.
 * Errors are logged for convenience.
*/
bool checkArguments(CommandLineArguments arguments) {
    // Check that the movie file is readable
    cv::VideoCapture cap(arguments.file);
    if (!cap.isOpened()) {
        spdlog::error("Unable to open the movie file: {}", arguments.file);
        return FAILURE;
    }

    // Check that the arguments are non-negative and do not exceed extreme bounds
    int totalFrames = cap.get(cv::CAP_PROP_FRAME_COUNT);
    if (arguments.rate <= 0) {
        spdlog::error("Sampling rate must be greater than 0.");
        return FAILURE;
    }
    if (arguments.batches <= 0) {
        spdlog::error("Number of batches must be greater than 0.");
        return FAILURE;
    }
    if (arguments.workers <= 0) {
        spdlog::error("Number of workers must be greater than 0.");
        return FAILURE;
    }
    if (arguments.rate >= totalFrames) {
        spdlog::error("Sampling rate must be less than the total number of frames.");
        return FAILURE;
    }
    int usedFrames = totalFrames / arguments.rate;
    if (arguments.batches >= usedFrames) {
        spdlog::error("Number of batches must be less than the number of used frames.");
        return FAILURE;
    }

    // Otherwise, release the capture and indicate success
    cap.release();
    return SUCCESS;
}

/*
 * process()
 * ---------
 * This function processes a batch of frames by computing its row-wise average.
 * The result is neatly placed in a results vector based on the batch's id.
*/
void process(Batch batch, std::vector<cv::Mat> &results) {
    cv::Mat concatenation;
    cv::Mat average;
    cv::hconcat(batch.frames, concatenation);
    cv::reduce(concatenation, average, 1, cv::REDUCE_AVG);

    // Store the result
    // thread-safe because we never modify the same memory
    results[batch.id] = average;
    spdlog::info("Processed batch {}.", batch.id);
}

/*
 * read()
 * ------
 * This function reads frames from the capture and batches them based on the 
 * sampling rate specified. Once packaged, batches are allocated to worker threads.
*/
void read(boost::asio::thread_pool &pool, std::vector<cv::Mat> &results, CommandLineArguments arguments) {
    // Open the video file
    cv::VideoCapture cap(arguments.file);
    cap.open(arguments.file);

    // Compute frames per batch
    int totalFrames = cap.get(cv::CAP_PROP_FRAME_COUNT);
    int usedFrames = totalFrames / arguments.rate;
    int framesPerBatch = usedFrames / arguments.batches;

    // Setup Counters and Current Batch
    int frameCounter = 0;
    int batchCounter = 0;
    Batch currentBatch;
    currentBatch.id = batchCounter;

    // While batches are remaining...
    while (batchCounter < arguments.batches) {
        // Grab frames from the capture
        cap.grab();

        // Retrieve frames based on the sampling rate
        if (frameCounter % arguments.rate == 0) {
            cv::Mat frame;
            cap.retrieve(frame);
            currentBatch.frames.push_back(frame);

            // Allocate the current batch if its size threshold is met
            if (currentBatch.frames.size() == framesPerBatch) {
                boost::asio::post(pool, [currentBatch, &results]() {process(currentBatch, results);});
                spdlog::info("Allocated batch {}.", currentBatch.id);
                batchCounter++;
                currentBatch = Batch{batchCounter};
            }
        }
        frameCounter++;
    }
    spdlog::info("Finished reading frames.");
}

/*
 * polarTransform()
 * ----------------
 * This helper function remaps the barcode (passed by reference) from cartesian to 
 * polar space in-place.
*/
void polarTransform(cv::Mat &barcode, int nBatches) {
    // Prepare for transformation
    cv::Mat flipped;
    cv::Mat polarImage;
    cv::rotate(barcode, flipped, cv::ROTATE_90_CLOCKWISE);
    
    // Apply transformation
    cv::Size dsize(nBatches, nBatches);
    cv::Point2f center(nBatches / 2.0f, nBatches / 2.0f);
    int maxRadius = nBatches / 2.0f;
    cv::warpPolar(flipped, polarImage, dsize, center, maxRadius, cv::WARP_INVERSE_MAP);

    // Create a circular mask to make pixels outside the circle transparent
    cv::Mat mask = cv::Mat::zeros(polarImage.size(), CV_8UC1);
    cv::circle(mask, center, maxRadius, cv::Scalar(255), -1);

    // Apply the mask to make pixels outside the circle transparent
    cv::cvtColor(polarImage, polarImage, cv::COLOR_BGR2BGRA);
    polarImage.setTo(cv::Scalar(0, 0, 0, 0), mask == 0);
    barcode = polarImage;
}

int main(int argc, char** argv) {
    // Parse and Check arguments
    CommandLineArguments arguments;
    if (!parseArguments(argc, argv, arguments)) {
        return 1;
    }
    if (!checkArguments(arguments)) {
        return 1;
    }
    if (!arguments.verbose) {
        spdlog::set_level(spdlog::level::off);
    }

    // Setup Results and ThreadPool
    std::vector<cv::Mat> results(arguments.batches);
    boost::asio::thread_pool pool(arguments.workers);
    read(pool, results, arguments);
    pool.join();

     // Package the vector into a single matrix
    cv::Mat barcode;
    cv::hconcat(results, barcode);

     // Apply polar transform is requested
    if (arguments.transform) {
        polarTransform(barcode, arguments.batches);
    }

    // Save the barcode image
    cv::imwrite("../barcode.png", barcode);
    spdlog::info("Barcode saved as barcode.png");

    return 0;
}
\$\endgroup\$

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.