Skip to content

Creating a generic camera

As mentioned previously, we originally divided our codebase to be camera-specific. The overarching goal is to merge both codebases again. To do so we need we need to convert the data from each specific camera into a "common" camera, but we also have a few goals about how to work with the code.

Goals

  • Automatically detect which camera was used based on the data.

I don't want the user to have to configure the program depending on the camera they used

  • Make it easy to add other cameras in the future

This is particularly important. Writing maintainable code involves trying to isolate parts of the code that are prone to change.

Original Workflow

For each camera, there is a codebase that reads each .csv file from hard links in the code. It uses readmatrix which loads the data into matrices, losing out on header names.

filename=fullfile(root,'Femur medial.csv');
rawData = readmatrix(filename);
XYZdataT{1} = rawData(:,all(~isnan(rawData))); %NaN where data is missing. this line gets rid of missing data points

filename=fullfile(root,'Femur lateral.csv');
rawData = readmatrix(filename);
XYZdataT{2} = rawData(:,all(~isnan(rawData)));

filename=fullfile(root,'Femur proximal.csv');
rawData = readmatrix(filename);
XYZdataT{3} = rawData(:,all(~isnan(rawData)));

The data is then read from numbered columns, not from header names. The original author knew this was a long term problem and even included a comment about it. The positions did indeed change, and you will notice TX, TY, TZ are now in columns 11:13

FemurMedial=mean(XYZdataT{1}(:,10:12)); % probe are rows 10:12 but this may change!
FemurLateral=mean(XYZdataT{2}(:,10:12));
FemurProximal=mean(XYZdataT{3}(:,10:12));

Improving the original workflow

In short, the problems are: - Different codebases for each camera

  • Susceptible to small changes in data format

  • Hard-coded links to files. We won't discuss how this was solved, but you should know it is not best practice.

Let's go over how we tackled these problems:

Enumerating all possible cameras

Since we know the whole list of possible cameras, one solution is to formally enumerate them. What this means is that a Camera must be a specific variant within an exhaustive list of cameras(1). When we add a new camera to our lab, the code has to reflect that. Later, as we implement this functionality in full, it forces you to explicitly say, in one place, what happens to every camera variant and how to handle an unknown camera.

  1. There is an enumeration of all possible cameras, and it must an element of that enumeration. Because it is a enumeration (not just a list of strings), the editor can even suggest the variants -- similar to usage of plot(), scatter() or many other built-in functions

We create the exhaustive list using an enumeration:

% Camera.m
classdef Camera
    enumeration
        Certus
        Polaris
        Unknown
    end
end

You can access each type by calling class.variant, like so:

my_camera = Camera.Certus;
switch  my_camera
    case Camera.Certus
        disp("It's a Certus camera!")
    case Camera.Polaris
        disp("Now it's a Polaris")
    case Camera.Unknown
        disp("Camera of unknown type")
end

Automatic camera detection

As a reminder, the data for each camera has the following format

Tools   Port 0x01   Femur tracker-Y Frame       Time [sec]          State   Q0      Qx      Qy      Qz    Tx      Ty       Tz         Error
3       Port 0x01   Femur tracker-Y 1391437402  1727257823.36817    OK      -0.18   -0.58   0.75   -0.28  57.82   -87.18   -2688.41   0.2021692
3       Port 0x01   Femur tracker-Y 1391437405  1727257823.41817    OK      -0.18   -0.58   0.75   -0.28  57.72   -87.24   -2688.37   0.217724
Frame   Femur q0    Femur qx    Femur qy    Femur qz    Femur x     Femur y     Femur z     Femur Error 
1       -0.18       -0.58       0.75        -0.28       57.82       -87.18      -2688.41    0.08        
2       -0.18       -0.58       0.75        -0.28       57.72       -87.24      -2688.37    0.08        

Retaining information

Since the two cameras have different data formats, we can use the data format itself to identify which camera it came from. In order to do so, we need to preserve the column names when reading the data, which we can achieve by reading the .csv as tables, not as matrices, as was previously done.

With preserved headers, we can access easily them:

data = readtable(filename);
headers = data.Properties.VariableNames;

>> disp(headers)
{Tools, Port_0x01, Femur_tracker-Y, Frame, Time_sec_, State, Q0, Qx, Qy, Qz, Tx, Ty, Tz, Error}
>> disp(headers)
{Frame, Femur_q0, Femur_qx, Femur_qy, Femur_qz, Femur_x, Femur_y, Femur_z, Femur_Error}

We can use any particular feature of these files for automatic detection. Let's use the name of the first header.

Camera detection

function camera = detect_camera(headers)
    header = headers{1};
    if strcmpi(header, 'tools')
        camera = Camera.Polaris;
    elseif strcmpi(header, 'frame')
        camera = Camera.Certus;
    else
        camera = Camera.Unknown;
    end
end

We can test this works with some arbitrary headers:

header_polaris = {'Tools', 'a', 'b', 'c'};
polaris = detect_camera(header_polaris);
header_certus = {'Frame', 'a', 'b', 'c'};
certus = detect_camera(header_certus);
header_unknown = {'a', 'b', 'c'};
unknown= detect_camera(header_unknown);

We now have a function that takes in headers from a data file, and tells us what kind of camera it is. We can use this function to apply the correct pre-processing for that camera, and get it ready for a common representation across all cameras.

Creating a common structure

These cameras track light sensors which we call 'trackers'. We represent these in software with a class called Tracker which holds camera data and sensor metadata, such as where in the body the sensor was attached.

We will tie the creation of trackers to the detection of cameras. This is because if/when we add more camera variations, there is an easy place to define how each file must be interpreted or modified.

% Camera.m
classdef Camera
    enumeration
        Certus
        Polaris
        Unknown
    end
    methods
        function tracker = into_tracker(self, data_camera, metadata)
            switch self
                case Camera.Certus
                    % Pre-process data as necessary
                    data_for_tracker = load_data_certus(data_camera)
                    % Create a Tracker
                    tracker = Tracker(data_for_tracker, metadata);
                case Camera.Polaris
                    data_for_tracker = load_data_polaris(data_camera)
                    tracker = Tracker(data_for_tracker, metadata);
                case Camera.Unknown
                    error("Unknown camera type)
            end
        end
    end
end

Usage of this code then looks like this:

data = readtable(filename);
metadata = % ...
headers = data.Properties.VariableNames;
camera = detect_camera(headers);

tracker = camera.into_tracker(data, metadata);

It works, but it is not how I would like to interact with the code. It's a little too convoluted. We load data, extract headers to detect camera, and then use data again later on. Why not pass data to detect_camera and it handles headers internally?

After implementing an idea, it is good practice to iterate not just on the "business" logic, but also the way we interact with the code.

Let's refactor some of this code until we are happy!

Refactoring

I would like to interact with this code in a way that is more similar to how we talk about what happens in the real world.

  • Include a step where the data are validated

  • The detection of the camera should be done behind the scenes

  • Tie camera data to specific trackers. I.e. the data from a file refers to a tracker placed in a specific place, and the program should know that.

I want to interact with the code as follows:

data = readtable(filename);
metadata = ...
tracker = Tracker.from_table(data, metadata);

Explanation on Tracker.from_table()

We have seen before this syntax:

my_dog = Dog("Fido");
my_dog.bark()
In this case, we would say "bark() is a method of the Dog class". That means you need an instance of the Dog class to call bark() on. Think about it as: you must have an actual dog to ask it to bark.

Tracker.from_table() is slightly different. from_table() is a "static method" -- It doesn't require an instance of the class to be called. We access it by calling the class itself. Think about it as a namespace. Many classes could have a from_table() method, but Tracker.from_table() is specifically how to create a Tracker from a table.

Tracker as a common representation

The Tracker class acts as a "contract" so that all cameras create an object that is exactly the same. We will handle the exact data that Tracker holds in a moment. For now, let's define the from_table() static method.

% Tracker.m
classdef Tracker
    % properties ...
    % methods ...
            % (1)!
    methods (Static) 
        function tracker = from_table(data_table, metadata)
            validation = validate_data(data_table);
            if ~validation.is_valid
                error("Invalid data because %s", validation.message);
            end

            data = Camera.load_data(data_table, metadata);

            tracker = Tracker(data, metadata);
        end
    end
end

% Dummy function to validate data
function validation = validate_data(data)
    validation.is_valid = true;
    validation.message = "OK";
end

  1. Above, we may have defined methods as usual. But methods defined in the block below will be static - they don't need an instance of the class (self).

We moved the validation of the data into the initial function call, but reading of the headers, detection of the camera and any peculiarities about loading each camera's data are all tied directly to Camera. Essentially, creating a Tracker through this route forces a validation step and allows the Camera class to handle all the camera-specific details.

Maybe we should limit a user's ability to only be able to create a Tracker through this function. We can actually do that if we limit access to the constructor. Let's flesh it out:

classdef Tracker
% Remember the headers: (1)
% The important ones go into properties
    properties
        Bone
        Label
        Q0, Qx, Qy, Qz
        Tx, Ty, Tz
    end

    methods (Access = private)
        function self = Tracker(data, metadata)
            self.Bone = metadata.bone;
            self.Label = metadata.label;
            [self.Q0, self.Qx, self.Qy, self.Qz] = deal(data.q0, data.qx, data.qy, data.qz);
            [self.Tx, self.Ty, self.Tz] = deal(data.tx, data.ty, data.tz);
        end
    end
    % methods (Static) ...
end
  1. {Tools, Port_0x01, Femur_tracker-Y, Frame, Time_sec_, State, Q0, Qx, Qy, Qz, Tx, Ty, Tz, Error}

The properties contain a mixture of important metadata (Bone, Label) and camera data (Q0, Qx, etc).

The method attribute Access = private means that an instance of Tracker can only be constructed from inside the class itself. I.e., the only point of entry into Tracker is through Tracker.from_table(). Trying to call Tracker() directly gives the error Cannot access method 'Tracker' in class 'Tracker'. Perfect.

It may not be what you always want to do, but in this case it forces the user through the safest, validated path.

The interface to the code is now exactly what we wanted:

data = readtable(filename);
metadata = ...
tracker = Tracker.from_table(data, metadata);

Now we can finish handling what is happening behind the scenes. Let's move over to Camera.

Detection of camera

You may have noticed that Tracker.from_table() called a static method from the Camera, Camera.load_data(). We created a singular way to load data, and internally Camera will handle it all.

Long term, as we add cameras and change definitions, this is the only place where definitions need to be updated. Every function that depends on camera information receives a uniform, unchanging data object.

Let's define it:

% Camera.m
classdef Camera
    enumeration
        Certus
        Polaris
        Unknown
    end
    methods (Static)
        function data = load_data(data_table)
            headers = data_table.Properties.VariableNames;
            camera = Camera.detect_from_headers(headers);
            data = struct( 'q0', [], 'qx', [], 'qy', [], 'qz', [], ...
                    'tx', [], 'ty', [], 'tz', []);

            switch camera
                case Camera.Certus
                    data = load_data_certus(data_table);
                case Camera.Polaris
                    data = load_data_polaris(data_table);
                case Camera.Unknown
                    error("Unknown camera type")
            end
        end

        function camera = detect_from_headers(headers)
            header = headers{1};
            if strcmpi(header, 'tools')
                camera = Camera.Polaris;
            elseif strcmpi(header, 'frame')
                camera = Camera.Certus;
            else
                camera = Camera.Unknown;
            end
        end
    end
end

We use methods (Static) to keep the enumeration of the camera types tied closely to how we detect the camera. detect_from_headers is also a static method. There is no particular reason why it should be. If there is never a case where we want to detect the camera in another context, it could just be a function inside the Camera.m file, which is only accessible within the file, similar to the dummy functions we will define next.

We create dummy functions for loading data just so the code runs.

% Camera.m
classdef Camera
% ...
end

function data = load_data_certus(data_table)
    data = data_table;
end
function data = load_data_polaris(data_table)
    data = data_table;
end

And that's it. We can now automatically read files, detect the camera being used, and assign them to correct trackers. All of it done behind the scenes, while the user only had to call:

tracker = Tracker.from_table(data, metadata);
In the future, when we switch cameras, a user only needs to look into one file (Camera.m) to update the exhaustive list of Camera. Then, write a corresponding function to make the camera's data format fit the existing Tracker structure.