[libcamera-devel,v3] py: cam.py: Provide live graph of request metadata
diff mbox series

Message ID 20230202145129.22182-1-daniel.oakley@ideasonboard.com
State Superseded
Headers show
Series
  • [libcamera-devel,v3] py: cam.py: Provide live graph of request metadata
Related show

Commit Message

Daniel Oakley Feb. 2, 2023, 2:51 p.m. UTC
Metadata is very useful when improving specific camera configurations.
Currently, there is an argument to display the metadata in text form,
however this can be hard to visualise and spot changes or patterns over
time. Therefore this proposed patch adds an argument to display this
metadata in graph form.

The metadata graph has 3 optional parameters:
 - refresh, number of times a second to update the graph
 - buffer, amount of historic/previous data to show
 - graphs, number of graphs to split the metadata between
 - autoscale, whether or not to autoscale the axis so all the data fits

Displaying the graph does have some performance penalty, however this
has been mostly mitigated through the refresh parameter. Despite this,
graphing might not the best of ideas when using the camera to record or
save data. This is mainly for debugging purposes.

Suggested-by: Kieran Bingham <kieran.bingham@ideasonboard.com>
Signed-off-by: Daniel Oakley <daniel.oakley@ideasonboard.com>
---
This is the 3rd version of my graph-metadata patch, hopefully addressing
the issue of there being a lot of additional code in the main cam.py
which is optional. Most of the code has been moved into a
cam_metadata_graph.py file, which is imported at the start of cam.py (as
opposed to importing it twice throughout the code to make the
GraphDrawer and process the arguments). I have slightly tweaked the
error handling with the metadata-graph's arguments to catch a division
by zero error for refresh=0 and added a comment explanation for the
metadata-graph argument processor.
There should, again, be no functional change between V1 and V2

 src/py/cam/cam.py                |  22 +++
 src/py/cam/cam_metadata_graph.py | 232 +++++++++++++++++++++++++++++++
 2 files changed, 254 insertions(+)
 create mode 100644 src/py/cam/cam_metadata_graph.py

Patch
diff mbox series

diff --git a/src/py/cam/cam.py b/src/py/cam/cam.py
index 967a72f5..e5f6910a 100755
--- a/src/py/cam/cam.py
+++ b/src/py/cam/cam.py
@@ -6,6 +6,7 @@ 
 from typing import Any
 import argparse
 import binascii
+import cam_metadata_graph as metadata_graph
 import libcamera as libcam
 import libcamera.utils
 import sys
@@ -21,6 +22,7 @@  class CameraContext:
     opt_strict_formats: bool
     opt_crc: bool
     opt_metadata: bool
+    opt_metadata_graph: dict
     opt_save_frames: bool
     opt_capture: int
 
@@ -39,6 +41,7 @@  class CameraContext:
         self.id = 'cam' + str(idx)
         self.reqs_queued = 0
         self.reqs_completed = 0
+        self.graph_drawer = None
 
     def do_cmd_list_props(self):
         print('Properties for', self.id)
@@ -171,6 +174,9 @@  class CameraContext:
             self.stream_names[stream] = 'stream' + str(idx)
             print('{}-{}: stream config {}'.format(self.id, self.stream_names[stream], stream.configuration))
 
+    def initialize_graph_drawer(self, graph_arguments):
+        self.graph_drawer = metadata_graph.GraphDrawer(graph_arguments, self.camera.id)
+
     def alloc_buffers(self):
         allocator = libcam.FrameBufferAllocator(self.camera)
 
@@ -271,6 +277,12 @@  class CaptureState:
             for ctrl, val in reqmeta.items():
                 print(f'\t{ctrl} = {val}')
 
+        if ctx.opt_metadata_graph:
+            if not ctx.graph_drawer:
+                ctx.initialize_graph_drawer(ctx.opt_metadata_graph)
+
+            ctx.graph_drawer.update_graph(ts, req.metadata)
+
         for stream, fb in buffers.items():
             stream_name = ctx.stream_names[stream]
 
@@ -393,6 +405,7 @@  def main():
     parser.add_argument('--crc', nargs=0, type=bool, action=CustomAction, help='Print CRC32 for captured frames')
     parser.add_argument('--save-frames', nargs=0, type=bool, action=CustomAction, help='Save captured frames to files')
     parser.add_argument('--metadata', nargs=0, type=bool, action=CustomAction, help='Print the metadata for completed requests')
+    parser.add_argument('--metadata-graph', nargs='*', type=str, const=True, action=CustomAction, help='Live graph of metadata. Default args: graphs=3 buffer=100 autoscale=true refresh=10')
     parser.add_argument('--strict-formats', type=bool, nargs=0, action=CustomAction, help='Do not allow requested stream format(s) to be adjusted')
     parser.add_argument('-s', '--stream', nargs='+', action=CustomAction)
     args = parser.parse_args()
@@ -418,6 +431,15 @@  def main():
         ctx.opt_metadata = args.metadata.get(cam_idx, False)
         ctx.opt_strict_formats = args.strict_formats.get(cam_idx, False)
         ctx.opt_stream = args.stream.get(cam_idx, ['role=viewfinder'])
+
+        ctx.opt_metadata_graph = args.metadata_graph.get(cam_idx, False)
+        if ctx.opt_metadata_graph is not False:
+            ctx.opt_metadata_graph = metadata_graph.process_args(ctx.opt_metadata_graph)
+            # Invalid argument parameters return False
+            if ctx.opt_metadata_graph is False:
+                print('Invalid --metadata_graph arguments, see --help')
+                sys.exit(-1)
+
         contexts.append(ctx)
 
     for ctx in contexts:
diff --git a/src/py/cam/cam_metadata_graph.py b/src/py/cam/cam_metadata_graph.py
new file mode 100644
index 00000000..dcef8595
--- /dev/null
+++ b/src/py/cam/cam_metadata_graph.py
@@ -0,0 +1,232 @@ 
+import libcamera.utils
+
+# GraphDrawer is a class that manages drawing the graph. It sets up the graph;
+# manages graph settings; holds the metadata buffer (previous + current values);
+# and updates the graph.
+# We store physical lines for each metadata as their x (time) and y (data) can
+# be directly updated instead of redrawing each axis - improves performance.
+class GraphDrawer:
+    graph_number: int
+    buffer_size: int
+    autoscale_axis: bool
+    refresh_time: float
+
+    BLOCKED_TYPES = [list, tuple, libcamera._libcamera.Rectangle]
+
+    def __init__(self, graph_arguments, camera_name):
+        # We only import matplotlib here to reduce mandatory dependencies
+        import matplotlib.pyplot as plt
+        self.plt = plt
+
+        self.graph_number = graph_arguments["graphs"]
+        self.buffer_size = graph_arguments["buffer"]
+        self.autoscale_axis = graph_arguments["autoscale"]
+        # Convert FPS into a duration in nanoseconds
+        self.refresh_time = 1 / graph_arguments["refresh"] * 10**9
+        self.previous_time = 0
+
+        self.figure = plt.figure(camera_name)
+        self.axis = []
+        self.axis_group = {}
+        self.metadata_buffer = {}
+        self.lines = {}
+        self.blit_manager = self.BlitManager(self.figure.canvas)
+
+        # Define the margins for drawing the graphs
+        self.figure.subplots_adjust(
+            top=0.95,
+            bottom=0.05,
+            left=0.1,
+            right=0.95,
+            hspace=0.2,
+            wspace=0.2
+        )
+
+        self.plt.show(block=False)
+
+    def update_graph(self, time, metadata):
+        # On the first request, allocate the each metadata entry to an axis
+        if len(self.axis) == 0:
+            self.__divide_subplots(self.graph_number, metadata)
+            self.plt.draw()
+
+        for ctrl, val in metadata.items():
+            if type(val) in self.BLOCKED_TYPES or ctrl.name in ["SensorTimestamp"]:
+                continue
+
+            # The metadata_buffer holds an x array and a y array for each ctrl
+            # where x is the time series and y is the metadata value
+            if not self.metadata_buffer.get(ctrl.name, False):
+                self.metadata_buffer[ctrl.name] = [[], []]
+
+            # Create the lines and configure their style on the graph
+            if not self.lines.get(ctrl.name, False):
+                self.lines[ctrl.name], = self.axis_group[ctrl].plot([], label=ctrl.name)
+                self.blit_manager.add_artist(self.lines[ctrl.name])
+                self.lines[ctrl.name].set_alpha(0.7)
+                self.lines[ctrl.name].set_linewidth(2.4)
+
+            self.metadata_buffer[ctrl.name][0].append(time)
+            self.metadata_buffer[ctrl.name][1].append(val)
+            # Remove the oldest entry to keep the buffer fixed at its max size
+            if len(self.metadata_buffer[ctrl.name][0]) > self.buffer_size:
+                del self.metadata_buffer[ctrl.name][0][0]
+                del self.metadata_buffer[ctrl.name][1][0]
+
+        if time - self.previous_time >= self.refresh_time:
+            self.__animate()
+            self.previous_time = time
+
+    # This method allocates the metadata into the correct number of graphs based
+    # on how different their example metadata is from their consecutive neighbour
+    def __divide_subplots(self, number_of_graphs, example_metadata):
+        # Create the correct number of axis positioned in a vertical stack
+        for i in range(1, number_of_graphs + 1):
+            axis = self.plt.subplot(number_of_graphs, 1, i)
+            # Remove the visible x axis as it is not redrawn with blit
+            axis.get_xaxis().set_ticks([])
+            self.axis.append(axis)
+
+        cleansed_metadata = {}
+        for ctrl, val in example_metadata.items():
+            if not (type(val) in self.BLOCKED_TYPES or ctrl.name in ["SensorTimestamp"]):
+                cleansed_metadata[ctrl] = val
+
+        # Summery of what the following code does:
+        # We first sort the metadata items by value so we can identify the
+        # difference between them.
+        # From there we can split them up based on the differences between them.
+        # We do this by sorting the the ctrls by their differences, and then
+        # adding N breaks into the standardly sorted list of ctrls (by values)
+        # next to those name. Where N is the number of graphs we want.
+        # We then go through the ctrls in order, adding them to the correct
+        # axis group (increasing their group if a break is found)
+
+        # Sort the metadata lowest to highest so consecutive values can be compared
+        sorted_metadata = dict(sorted(cleansed_metadata.items(),
+                               key=lambda item: item[1]))
+
+        # Create the dictionary containing the {ctrl:percentage difference}
+        percent_diff = {}
+        prev_val = None
+        for ctrl,val in sorted_metadata.items():
+            if prev_val:
+                percent_diff[ctrl] = val/prev_val
+
+            prev_val = val
+
+        # Sort those percentage differences, highest to lowest, so we can find
+        # the appropriate break points to separate the metadata by
+        sorted_diffs = dict(sorted(percent_diff.items(),
+                            key=lambda item: item[1], reverse=True))
+        # This the list of ctrls ordered by value, lowest to highest
+        sorted_ctrl = sorted(sorted_metadata, key=sorted_metadata.get)
+
+        # Add the correct number breaks in, starting with the break that
+        # separates the greatest distance
+        i = 0
+        for ctrl, _ in sorted_diffs.items():
+            i += 1
+            if i >= number_of_graphs:
+                break
+
+            sorted_ctrl.insert(sorted_ctrl.index(ctrl), "~BREAK~")
+
+        # Put the ctrls with their correct axis group, incrementing group when
+        # a break is found.
+        group = 0
+        for ctrl in sorted_ctrl:
+            if ctrl == "~BREAK~":
+                group += 1
+            else:
+                self.axis_group[ctrl] = self.axis[group]
+
+    def __animate(self):
+        for ctrl, series in self.metadata_buffer.items():
+            self.lines[ctrl].set_xdata(series[0])
+            self.lines[ctrl].set_ydata(series[1])
+
+        # Scale and display the legend on the axis
+        for axis in self.axis:
+            axis.relim()
+            axis.legend(loc="upper left")
+            axis.autoscale_view()
+
+            # Adjust the y scale to be bigger once if manual scaling
+            if axis.get_autoscaley_on() and not self.autoscale_axis:
+                axis_ymin, axis_ymax = axis.get_ybound()
+                axis_yrange = axis_ymax - axis_ymin
+                axis.set_ybound([axis_ymin - axis_yrange * 0.25,
+                                axis_ymax + axis_yrange * 0.25])
+
+            axis.set_autoscaley_on(self.autoscale_axis)
+            axis.set_autoscalex_on(True)
+
+        self.blit_manager.update()
+
+    # This BlitManager is derived from: (comments removed and init simplified)
+    # matplotlib.org/devdocs/tutorials/advanced/blitting.html#class-based-example
+    # The BlitManager manages redrawing the graph in a performant way. It also
+    # prompts a full re-draw (axis and legend) whenever the window is resized.
+    class BlitManager:
+        def __init__(self, canvas):
+            self.canvas = canvas
+            self._bg = None
+            self._artists = []
+
+            self.cid = canvas.mpl_connect("draw_event", self.on_draw)
+
+        def on_draw(self, event):
+            cv = self.canvas
+            if event is not None:
+                if event.canvas != cv:
+                    raise RuntimeError
+            self._bg = cv.copy_from_bbox(cv.figure.bbox)
+            self._draw_animated()
+
+        def add_artist(self, art):
+            if art.figure != self.canvas.figure:
+                raise RuntimeError
+            art.set_animated(True)
+            self._artists.append(art)
+
+        def _draw_animated(self):
+            fig = self.canvas.figure
+            for a in self._artists:
+                fig.draw_artist(a)
+
+        def update(self):
+            cv = self.canvas
+            fig = cv.figure
+            if self._bg is None:
+                self.on_draw(None)
+            else:
+                cv.restore_region(self._bg)
+                self._draw_animated()
+                cv.blit(fig.bbox)
+            cv.flush_events()
+
+
+# This method processes the --metadata_graph arguments into a dictionary. If
+# an error occurs while doing that (not valid data type or 0 value for refresh)
+# false will be returned and the main script will exit with an error.
+# Non-existing arguments are ignored.
+def process_args(arguments):
+    mdg_args = []
+    for i in arguments:
+        mdg_args += i.split("=")
+    try:
+        args_dict = {
+            "graphs":  (3 if "graphs" not in mdg_args else
+                       int(mdg_args[mdg_args.index("graphs") + 1])),
+            "buffer":  (100 if "buffer" not in mdg_args else
+                       int(mdg_args[mdg_args.index("buffer") + 1])),
+            "refresh": (10.0 if "refresh" not in mdg_args else
+                       1/1/float(mdg_args[mdg_args.index("refresh") + 1])),
+            "autoscale": (True if "autoscale" not in mdg_args else
+                (mdg_args[mdg_args.index("autoscale") + 1]).lower() == "true")
+        }
+        return args_dict
+
+    except (ValueError, ZeroDivisionError):
+        return False