This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Shared appearance and PNG/SVG export for PIM result plots."""
|
||||
"""Shared appearance and PNG/SVG/PDF export for PIM result plots."""
|
||||
|
||||
from pathlib import Path
|
||||
from math import ceil
|
||||
@@ -12,9 +12,32 @@ from matplotlib.patheffects import AbstractPathEffect
|
||||
from matplotlib.transforms import Affine2D, IdentityTransform, TransformedPath
|
||||
|
||||
|
||||
TEXT_COLOR = "#243449"
|
||||
HATCH_COLOR = "#C62828"
|
||||
COLORS = ("#2B65A1", "#168C8B", "#D18D32", "#8062A5", "#B65D60")
|
||||
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):
|
||||
@@ -77,51 +100,130 @@ def configure_style() -> None:
|
||||
"text.color": TEXT_COLOR,
|
||||
"xtick.color": TEXT_COLOR,
|
||||
"ytick.color": TEXT_COLOR,
|
||||
"axes.edgecolor": "#CFD6DF",
|
||||
"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) -> None:
|
||||
fontsize: float = 8, invalid_label: str = "INVALID") -> None:
|
||||
alignment = "bottom" if direction > 0 else "top"
|
||||
rotation = 90 if direction > 0 else -90
|
||||
text = ax.text(x, y, label, rotation=rotation, ha="center", va=alignment,
|
||||
fontsize=fontsize, color=TEXT_COLOR)
|
||||
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":
|
||||
height = text.get_window_extent(ax.figure.canvas.get_renderer()).height
|
||||
offset = direction * (height * 72 / ax.figure.dpi + 3)
|
||||
ax.annotate("INVALID" if status == "FAIL" else status, (x, y),
|
||||
xytext=(0, offset), textcoords="offset points",
|
||||
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=HATCH_COLOR)
|
||||
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:
|
||||
"""Reserve vertical space for rotated labels using their rendered size."""
|
||||
"""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)
|
||||
labels = []
|
||||
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))
|
||||
lower, upper = ax.get_ylim()
|
||||
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)])
|
||||
limits.append((lower, upper))
|
||||
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(min(lower for lower, _ in limits), max(upper for _, upper in limits))
|
||||
ax.set_ylim(lower, upper)
|
||||
|
||||
|
||||
def save_figure(fig, basename: str, output_dir: Path) -> None:
|
||||
@@ -129,7 +231,7 @@ def save_figure(fig, basename: str, output_dir: Path) -> None:
|
||||
hatch = patch.get_hatch() or ""
|
||||
if any(symbol in hatch for symbol in "./\\"):
|
||||
patch.set_path_effects([CenteredHatch()])
|
||||
for extension in ("png", "svg"):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user