| #!/usr/bin/env python3 | |
| ## Copyright (c) 2023, Alliance for Open Media. All rights reserved | |
| ## | |
| ## This source code is subject to the terms of the BSD 3-Clause Clear License and the | |
| ## Alliance for Open Media Patent License 1.0. If the BSD 3-Clause Clear License was | |
| ## not distributed with this source code in the LICENSE file, you can obtain it | |
| ## at aomedia.org/license/software-license/bsd-3-c-c/. If the Alliance for Open Media Patent | |
| ## License 1.0 was not distributed with this source code in the PATENTS file, you | |
| ## can obtain it at aomedia.org/license/patent-license/. | |
| ## | |
| """Matplotlib visualization library for AVM Frame protos. | |
| Defines a number of composable frame visualization layers that can be used to | |
| show some data of interest within a specific frame in a Colab notebook. | |
| The typical use case will start with some representation of the underlying pixel | |
| data, and then render annotation additional layers on top of it, which will get | |
| combined together into the final plot. | |
| An example use case: | |
| ``` | |
| viz = (ReconstructionYuvLayer() | |
| .add(TransformBlockLayer()) | |
| .add(PartitionTreeLayer()) | |
| .add(SuperblockLayer()) | |
| fig, ax = plt.subplots(1, 1) | |
| viz.show(frame, ax) | |
| ``` | |
| This will render the reconstructed YUV as the base layer, and then draw on top | |
| the transform units, the partition tree, and the superblock boundaries. | |
| """ | |
| from __future__ import annotations | |
| import abc | |
| from avm_stats import proto_helpers | |
| import matplotlib.pyplot as plt | |
| from mpl_toolkits.axes_grid1 import inset_locator | |
| import numpy as np | |
| class VisualizationLayer(metaclass=abc.ABCMeta): | |
| """Abstract base class for all visualization layers. | |
| Attributes: | |
| layers: list of additional layers that will be rendered on top of this one. | |
| The additional layers will be rendered bottom to top. | |
| """ | |
| def __init__(self): | |
| self.layers = [] | |
| def add(self, layer: VisualizationLayer) -> VisualizationLayer: | |
| """Adds another visualization layer to the list of layers. | |
| Args: | |
| layer: Additional layer to render. | |
| Returns: | |
| self, so that additional add() calls can be chained together. | |
| """ | |
| self.layers.append(layer) | |
| return self | |
| @abc.abstractmethod | |
| def render(self, frame: proto_helpers.Frame, ax: plt.Axes): | |
| """Render this visualization to the Matplotlib axes. | |
| Args: | |
| frame: Frame used to get render data from, e.g. pixels or coding units. | |
| ax: Matplotlib axes to render to. | |
| """ | |
| def show(self, frame: proto_helpers.Frame, ax: plt.Axes): | |
| self.render(frame, ax) | |
| for layer in self.layers: | |
| layer.render(frame, ax) | |
| class YuvPlaneLayer(VisualizationLayer): | |
| """Visualize a single YUV plane. | |
| Attributes: | |
| name: Name of the layer; will be displayed in the plot title. | |
| pixels_attribute: The attribute of the frames PlaneBuffer to plot pixels | |
| from. | |
| plane: Which YUV plane to plot. | |
| """ | |
| def __init__( | |
| self, | |
| name: str, | |
| pixels_attribute: str, | |
| *, | |
| plane: proto_helpers.Plane = proto_helpers.Plane.Y, | |
| ): | |
| self.name = name | |
| self.pixels_attribute = pixels_attribute | |
| self.plane = plane | |
| super().__init__() | |
| def render(self, frame: proto_helpers.Frame, ax: plt.Axes): | |
| ax.title.set_text( | |
| f"{self.name} ({self.plane.name})\nframe={frame.frame_id}" | |
| ) | |
| pixels = getattr(frame.pixels[self.plane], self.pixels_attribute) | |
| width = frame.width | |
| height = frame.height | |
| vmax = 2 ** frame.bit_depth - 1 | |
| ax.imshow( | |
| pixels, cmap="gray", vmin=0, vmax=vmax, extent=[0, width, height, 0] | |
| ) | |
| class OriginalYuvLayer(YuvPlaneLayer): | |
| def __init__(self, **kwargs): | |
| super().__init__("Original", "original", **kwargs) | |
| class PredictionYuvLayer(YuvPlaneLayer): | |
| def __init__(self, **kwargs): | |
| super().__init__("Prediction", "prediction", **kwargs) | |
| class PrefilteredYuvLayer(YuvPlaneLayer): | |
| def __init__(self, **kwargs): | |
| super().__init__("Pre-filtered Reconstruction", "pre_filtered", **kwargs) | |
| class ReconstructionYuvLayer(YuvPlaneLayer): | |
| def __init__(self, **kwargs): | |
| super().__init__("Reconstruction", "reconstruction", **kwargs) | |
| class YuvDeltaLayer(VisualizationLayer): | |
| def __init__( | |
| self, | |
| plane: proto_helpers.Plane = proto_helpers.Plane.Y, | |
| show_relative=False, | |
| ): | |
| self.plane = plane | |
| self.show_relative = show_relative | |
| super().__init__() | |
| @abc.abstractmethod | |
| def get_pixels(self, frame: proto_helpers.Frame) -> np.ndarray: | |
| pass | |
| @abc.abstractmethod | |
| def get_name(self) -> str: | |
| pass | |
| def render(self, frame: proto_helpers.Frame, ax: plt.Axes): | |
| pixels = self.get_pixels(frame) | |
| pixel_min = np.min(pixels) | |
| pixel_max = np.max(pixels) | |
| vmax = 2 ** frame.bit_depth - 1 | |
| vmin = -vmax | |
| annotation = "Relative" if self.show_relative else "Absolute" | |
| if self.show_relative: | |
| vmin, vmax = pixel_min, pixel_max | |
| ax.title.set_text( | |
| f"{self.get_name()} ({self.plane.name}) - {annotation}" | |
| f"\nframe={frame.frame_id}, min={pixel_min}, max={pixel_max}" | |
| ) | |
| width = frame.width | |
| height = frame.height | |
| ax.imshow( | |
| pixels, cmap="gray", vmin=vmin, vmax=vmax, extent=[0, width, height, 0] | |
| ) | |
| class ResidualYuvLayer(YuvDeltaLayer): | |
| def __init__( | |
| self, | |
| plane: proto_helpers.Plane = proto_helpers.Plane.Y, | |
| show_relative=False, | |
| ): | |
| super().__init__(plane, show_relative) | |
| def get_pixels(self, frame: proto_helpers.Frame) -> np.ndarray: | |
| return frame.pixels[int(self.plane)].residual | |
| def get_name(self) -> str: | |
| return "Residual" | |
| class FilterDeltaYuvLayer(YuvDeltaLayer): | |
| def __init__( | |
| self, | |
| plane: proto_helpers.Plane = proto_helpers.Plane.Y, | |
| show_relative=False, | |
| ): | |
| super().__init__(plane, show_relative) | |
| def get_pixels(self, frame: proto_helpers.Frame) -> np.ndarray: | |
| return frame.pixels[int(self.plane)].filter_delta | |
| def get_name(self) -> str: | |
| return "Filter Delta" | |
| class DistortionYuvLayer(YuvDeltaLayer): | |
| def __init__( | |
| self, | |
| plane: proto_helpers.Plane = proto_helpers.Plane.Y, | |
| show_relative=False, | |
| ): | |
| super().__init__(plane, show_relative) | |
| def get_pixels(self, frame: proto_helpers.Frame) -> np.ndarray: | |
| return frame.pixels[int(self.plane)].distortion | |
| def get_name(self) -> str: | |
| return "Distortion" | |
| class SuperblockLayer(VisualizationLayer): | |
| """Draw the boundaries of the superblocks.""" | |
| def render(self, frame: proto_helpers.Frame, ax: plt.Axes): | |
| for superblock in frame.superblocks: | |
| rectangle = superblock.rect | |
| ax.add_patch( | |
| plt.Rectangle( | |
| (rectangle.left_x, rectangle.top_y), | |
| rectangle.width, | |
| rectangle.height, | |
| linewidth=2, | |
| edgecolor="blue", | |
| facecolor="none", | |
| ) | |
| ) | |
| class PartitionTreeLayer(VisualizationLayer): | |
| """Draw the boundaries of the partition tree leaf nodes.""" | |
| def __init__(self, use_chroma=False): | |
| self.use_chroma = use_chroma | |
| super().__init__() | |
| def render(self, frame: proto_helpers.Frame, ax: plt.Axes): | |
| for superblock in frame.superblocks: | |
| for rectangle in superblock.get_partition_rects(self.use_chroma): | |
| ax.add_patch( | |
| plt.Rectangle( | |
| (rectangle.left_x, rectangle.top_y), | |
| rectangle.width, | |
| rectangle.height, | |
| linewidth=1, | |
| edgecolor="red", | |
| facecolor="none", | |
| ) | |
| ) | |
| class TransformBlockLayer(VisualizationLayer): | |
| """Draw the boundaries of all transform units.""" | |
| def __init__(self, use_chroma=False): | |
| self.use_chroma = use_chroma | |
| super().__init__() | |
| def render(self, frame: proto_helpers.Frame, ax: plt.Axes): | |
| for superblock in frame.superblocks: | |
| for rectangle in superblock.get_transform_rects( | |
| use_chroma=self.use_chroma | |
| ): | |
| ax.add_patch( | |
| plt.Rectangle( | |
| (rectangle.left_x, rectangle.top_y), | |
| rectangle.width, | |
| rectangle.height, | |
| linestyle=":", | |
| linewidth=1, | |
| edgecolor="green", | |
| facecolor="none", | |
| ) | |
| ) | |
| class MotionVectorLayer(VisualizationLayer): | |
| MOTION_VECTOR_PRECISION = 8 | |
| def __init__(self, use_chroma=False): | |
| self.use_chroma = use_chroma | |
| super().__init__() | |
| def render(self, frame: proto_helpers.Frame, ax: plt.Axes): | |
| # Don't plot motion vectors for intra frames | |
| if frame.proto.frame_params.frame_type == 0: | |
| return | |
| for sb in frame.superblocks: | |
| for cu in sb.get_coding_units(use_chroma=self.use_chroma): | |
| for mv in cu.proto.prediction_mode.motion_vectors: | |
| if mv.ref_frame == -1: | |
| continue | |
| cx = cu.proto.position.x + cu.proto.size.width / 2 | |
| cy = cu.proto.position.y + cu.proto.size.height / 2 | |
| dx = mv.dx / self.MOTION_VECTOR_PRECISION | |
| dy = mv.dy / self.MOTION_VECTOR_PRECISION | |
| ax.add_patch( | |
| plt.Arrow( | |
| cx, | |
| cy, | |
| dx, | |
| dy, | |
| linewidth=1, | |
| edgecolor="green", | |
| ) | |
| ) | |
| class SuperblockAnnotationLayer(VisualizationLayer): | |
| def render(self, frame: proto_helpers.Frame, ax: plt.Axes): | |
| for sb in frame.superblocks: | |
| rect = sb.clipped_rect | |
| cx = rect.x + rect.width / 2 | |
| cy = rect.y + rect.height / 2 | |
| bits = sb.get_total_bits() | |
| qindex = sb.qindex | |
| ax.text( | |
| cx, | |
| cy, | |
| f"Bits: {bits:.1f}\nqindex={qindex}", | |
| verticalalignment="center", | |
| horizontalalignment="center", | |
| size="large", | |
| ) | |
| class BitsHeatmapLayer(VisualizationLayer): | |
| def __init__( | |
| self, | |
| use_chroma: bool = False, | |
| filt: proto_helpers.SymbolFilter | None = None, | |
| ): | |
| self.use_chroma = use_chroma | |
| self.filt = filt | |
| super().__init__() | |
| def render(self, frame: proto_helpers.Frame, ax: plt.Axes): | |
| heatmap = np.zeros((frame.height, frame.width)) | |
| # TODO(comc): allow min / max across multiple frames. | |
| min_bpp, max_bpp = 1e9, 0 | |
| for sb in frame.superblocks: | |
| bits_by_cu = list( | |
| sb.get_bits_per_coding_unit( | |
| use_chroma=self.use_chroma, filt=self.filt | |
| ) | |
| ) | |
| for i, rect in enumerate(sb.get_partition_rects(self.use_chroma)): | |
| bits = bits_by_cu[i] | |
| bpp = bits / (rect.width * rect.height) | |
| min_bpp = min(bpp, min_bpp) | |
| max_bpp = max(bpp, max_bpp) | |
| heatmap[ | |
| rect.top_y : rect.top_y + rect.height, | |
| rect.left_x : rect.left_x + rect.width, | |
| ] = bpp | |
| width = frame.width | |
| height = frame.height | |
| pos = ax.imshow( | |
| heatmap, | |
| cmap="YlOrRd", | |
| interpolation="none", | |
| vmin=min_bpp, | |
| vmax=max_bpp, | |
| extent=[0, width, height, 0], | |
| ) | |
| axins = inset_locator.inset_axes( | |
| ax, width="100%", height="5%", loc="lower center", borderpad=-5 | |
| ) | |
| ax.get_figure().colorbar(pos, cax=axins, orientation="horizontal") | |
| class PredictionModeAnnotationLayer(VisualizationLayer): | |
| def __init__(self, use_chroma: bool = False): | |
| self.use_chroma = use_chroma | |
| super().__init__() | |
| def render(self, frame: proto_helpers.Frame, ax: plt.Axes): | |
| for sb in frame.superblocks: | |
| for cu in sb.get_coding_units(use_chroma=self.use_chroma): | |
| rect = cu.rect | |
| intra_mode = cu.get_prediction_mode() | |
| cx = rect.left_x + rect.width / 2 | |
| cy = rect.top_y + rect.height / 2 | |
| mode = intra_mode.removesuffix("_PRED") | |
| ax.text(cx, cy, mode, horizontalalignment="center", size="small") |