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.
- 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()
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
- 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
{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);
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.