Files
Raptor/validation/tools/pim/plot_style.py
T
NiccoloN 49d6fb42cf
Validate Operations / validate-operations (push) Has been cancelled
update plots
2026-10-26 12:18:36 +01:00

240 lines
11 KiB
Python

"""Shared appearance and PNG/SVG/PDF export for PIM result plots."""
from pathlib import Path
from math import ceil
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
from matplotlib.path import Path as PlotPath
from matplotlib.patheffects import AbstractPathEffect
from matplotlib.transforms import Affine2D, IdentityTransform, TransformedPath
PALETTE = {
"sage": ("#B3D5CA", "#4B7C6A"),
"sand": ("#E1BC8B", "#856A44"),
"pale_blue": ("#EDF3FA", "#456B90"),
"blue": ("#B3CCE3", "#456B90"),
"pale_green": ("#EAF5F1", "#3E8271"),
"pale_sand": ("#FCF1E5", "#A87940"),
"red": ("#FF9494", "#C52020"),
"purple": ("#D1C0E0", "#77558F"),
}
TEXT_COLOR = "#222222"
MATRIX_TEXT_COLOR = "#1D2530"
ERROR_COLOR = PALETTE["red"][1]
GRID_COLOR = "#E6E9EC"
LINE_COLOR = "#C9CED3"
BAND_COLOR = "#F7F9FA"
COMPILER_STYLES = {"raptor": PALETTE["sage"], "pimcomp": PALETTE["sand"]}
METRIC_STYLES = {"latency": PALETTE["blue"], "throughput": PALETTE["sage"]}
NETWORK_STYLES = {"vgg8": PALETTE["blue"], "resnet18": PALETTE["sage"],
"resnet34": PALETTE["sand"], "googlenet": PALETTE["purple"],
"yolo11n": PALETTE["red"]}
COMPILER_NAMES = {"raptor": "Raptor", "pimcomp": "PIMCOMP"}
ARCH_NAMES = {"arch-a": "Arch-A", "arch-b": "Arch-B"}
MODEL_NAMES = {"vgg8": "VGG-8", "resnet18": "ResNet-18", "resnet34": "ResNet-34",
"googlenet": "GoogLeNet", "yolo11n": "YOLO11n"}
MODE_NAMES = {"latency": "Single-inference", "throughput": "Throughput"}
class CenteredHatch(AbstractPathEffect):
"""Anchor dot and diagonal hatch patterns to each rectangle's center."""
def draw_path(self, renderer, gc, tpath, affine, rgbFace=None):
hatch = gc.get_hatch() or ""
base_gc = renderer.new_gc()
base_gc.copy_properties(gc)
base_gc.set_hatch("".join(symbol for symbol in hatch if symbol not in "./\\"))
renderer.draw_path(base_gc, tpath, affine, rgbFace)
base_gc.restore()
bounds = affine.transform_path(tpath).get_extents()
center_x, center_y = (bounds.x0 + bounds.x1) / 2, (bounds.y0 + bounds.y1) / 2
vertices = []
diagonal = 2 ** -0.5
for symbols, dx, dy in (("/", diagonal, diagonal), ("\\", diagonal, -diagonal)):
count = sum(hatch.count(symbol) for symbol in symbols)
if count == 0:
continue
spacing = renderer.points_to_pixels(12 / count)
half_span = (abs(dy) * bounds.width + abs(dx) * bounds.height) / 2
half_length = (abs(dx) * bounds.width + abs(dy) * bounds.height) / 2
extent = ceil(half_span / spacing)
for step in range(-extent, extent + 1):
x = center_x - dy * step * spacing
y = center_y + dx * step * spacing
vertices.extend(((x - dx * half_length, y - dy * half_length),
(x + dx * half_length, y + dy * half_length)))
line_gc = renderer.new_gc()
line_gc.copy_properties(gc)
line_gc.set_hatch(None)
line_gc.set_foreground(gc.get_hatch_color())
line_gc.set_linewidth(gc.get_hatch_linewidth())
line_gc.set_clip_path(TransformedPath(tpath, affine))
if vertices:
renderer.draw_path(line_gc, PlotPath(vertices, [PlotPath.MOVETO, PlotPath.LINETO] * (len(vertices) // 2)),
IdentityTransform())
if "." in hatch:
spacing = renderer.points_to_pixels(12 / hatch.count("."))
x_extent = ceil(bounds.width / (2 * spacing))
y_extent = ceil(bounds.height / (2 * spacing))
centers = [(center_x + x * spacing, center_y + y * spacing)
for x in range(-x_extent, x_extent + 1)
for y in range(-y_extent, y_extent + 1)]
line_gc.set_linewidth(0)
renderer.draw_markers(line_gc, PlotPath.unit_circle(),
Affine2D().scale(renderer.points_to_pixels(0.9)),
PlotPath(centers), IdentityTransform(), gc.get_hatch_color())
line_gc.restore()
def configure_style() -> None:
plt.rcParams.update({
"font.family": "DejaVu Sans",
"axes.spines.top": False,
"axes.spines.right": False,
"axes.labelcolor": TEXT_COLOR,
"text.color": TEXT_COLOR,
"xtick.color": TEXT_COLOR,
"ytick.color": TEXT_COLOR,
"axes.edgecolor": LINE_COLOR,
"grid.color": GRID_COLOR,
"grid.linewidth": 0.8,
"hatch.color": TEXT_COLOR,
"savefig.facecolor": "white",
"figure.facecolor": "white",
})
def add_legend(fig, handles) -> None:
"""Use the fewest balanced legend rows that fit the figure width."""
renderer = fig.canvas.get_renderer()
for rows in range(1, len(handles) + 1):
columns = ceil(len(handles) / rows)
grid = []
start = 0
for row in range(rows):
count = len(handles) // rows + (row < len(handles) % rows)
grid.append(handles[start:start + count] + [None] * (columns - count))
start += count
ordered = [handle for column in zip(*grid) for handle in column]
legend = fig.legend(handles=[handle if handle is not None else Patch(visible=False) for handle in ordered],
labels=[handle.get_label() if handle is not None else "" for handle in ordered],
loc="outside upper center", ncols=columns, frameon=False, fontsize=9.5,
handlelength=1.7, columnspacing=1.5, handletextpad=0.6, labelspacing=0.4)
for handle, patch, text in zip(ordered, legend.legend_handles, legend.get_texts()):
if handle is None:
patch.set_visible(False)
text.set_visible(False)
if legend.get_window_extent(renderer).width <= fig.bbox.width - 20:
return
legend.remove()
def value_label(ax, x, y, label: str, status: str = "PASS", direction: int = 1,
fontsize: float = 8, invalid_label: str = "INVALID") -> None:
alignment = "bottom" if direction > 0 else "top"
rotation = 90 if direction > 0 else -90
offset = direction * fontsize * 0.65 if status != "PASS" else 0
ax.annotate(label, (x, y), xytext=(-offset, 0), textcoords="offset points",
rotation=rotation, ha="center", va=alignment, fontsize=fontsize,
color=TEXT_COLOR)
if status != "PASS":
ax.annotate(invalid_label if status == "FAIL" else status, (x, y),
xytext=(offset, 0), textcoords="offset points",
rotation=rotation, ha="center", va=alignment, fontsize=fontsize,
color=ERROR_COLOR if status == "FAIL" else TEXT_COLOR)
def fit_x_tick_labels(fig) -> None:
"""Rotate only overlapping labels, clockwise by 30 degrees."""
fig.canvas.draw()
renderer = fig.canvas.get_renderer()
for minor in (False, True):
labels = [(text, text.get_window_extent(renderer))
for ax in fig.axes for text in ax.get_xticklabels(minor=minor)
if text.get_visible() and text.get_text()]
crowded = set()
for index, (text, bounds) in enumerate(labels):
for other_text, other_bounds in labels[:index]:
if bounds.overlaps(other_bounds):
crowded.update((text, other_text))
for text in crowded:
text.set_rotation(-30)
text.set_rotation_mode("anchor")
text.set_ha("right" if text.get_va() == "bottom" else "left")
fig.canvas.draw()
renderer = fig.canvas.get_renderer()
for ax in fig.axes:
bottom_minor = [text for text in ax.get_xticklabels(minor=True)
if text.get_va() == "top" and text.get_visible() and text.get_text()]
if bottom_minor:
height = max(text.get_window_extent(renderer).height for text in bottom_minor)
ax.tick_params(axis="x", which="major", pad=height * 72 / fig.dpi + 6)
fig.canvas.draw()
def fit_bar_widths(fig) -> None:
"""Reserve enough horizontal space for each bar's rendered annotations."""
fig.canvas.draw()
renderer = fig.canvas.get_renderer()
scale = 1.0
for ax in fig.axes:
labels = {}
for text in ax.texts:
if hasattr(text, "xy"):
labels.setdefault(text.xy[0], []).append(text.get_window_extent(renderer))
for container in ax.containers:
for bar in container.patches:
bounds = labels.get(bar.get_x() + bar.get_width() / 2, [])
if bounds:
width = max(box.x1 for box in bounds) - min(box.x0 for box in bounds)
scale = max(scale, (width + renderer.points_to_pixels(2)) / bar.get_window_extent(renderer).width)
if scale > 1:
fig.set_size_inches(fig.get_figwidth() * scale, fig.get_figheight())
def fit_value_labels(fig) -> None:
"""Fit common vertical limits to the data and rendered value labels."""
for ax in fig.axes:
for text in ax.texts:
text.set_in_layout(False)
fig.canvas.draw()
renderer = fig.canvas.get_renderer()
limits = []
labels = []
for ax in fig.axes:
bounds = ax.get_window_extent(renderer)
for text in ax.texts:
x, y = text.xy if hasattr(text, "xy") else text.get_position()
anchor = ax.transData.transform((x, y))[1]
extent = text.get_window_extent(renderer)
labels.append((y, max(0, anchor - extent.y0 + 3) / bounds.height,
max(0, extent.y1 - anchor + 3) / bounds.height))
limits.append(ax.dataLim.intervaly if ax.has_data() else ax.get_ylim())
lower = min(0, *(lower for lower, _ in limits))
upper = max(0, *(upper for _, upper in limits))
if lower == upper:
upper = lower + 1
for _ in range(12):
lower = min([lower, *((y - below * upper) / (1 - below) for y, below, _ in labels)])
upper = max([upper, *((y - above * lower) / (1 - above) for y, _, above in labels)])
for ax in fig.axes:
ax.set_ylim(lower, upper)
def save_figure(fig, basename: str, output_dir: Path) -> None:
for patch in fig.findobj(Patch):
hatch = patch.get_hatch() or ""
if any(symbol in hatch for symbol in "./\\"):
patch.set_path_effects([CenteredHatch()])
for extension in ("png", "svg", "pdf"):
target = output_dir / extension / f"{basename}.{extension}"
target.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(target, dpi=220, bbox_inches="tight", pad_inches=0.04)
print(target)
plt.close(fig)