如何获取Matplotlib Text对象的旋转锚点实现刻度标签等文本自动缩放
我正在尝试自动缩放Matplotlib绘图中的Text对象(本文以刻度标签为核心研究对象),避免文本出现重叠,该能力用于一个CLI程序的原型绘图模块,该模块需要动态输出多种不同类型、不同尺寸的图表。
当前实现方案中,我需要获取任意旋转的matplotlib.text.Text bounding box的旋转锚点,核心问题为:如何获取Text(...).get_window_extent()返回的Bbox对应的旋转锚点坐标,最好坐标单位与Bbox返回的坐标系一致?
为避免出现XY问题,我补充更多背景信息:plt.tight_layout()和constrained_layout无法覆盖所有Text对象的缩放场景,为每一种图表类型推导鲁棒的文本缩放启发式规则可行性极低,我暂时接受通过重绘来检测文本重叠的方案。
问题演示动画:
如果Text对象被旋转,其返回的Bbox是覆盖文本范围的轴对齐矩形,和动画中绘制的红/绿框不同,该矩形无法代表文本实际覆盖的区域,因此直接调用Bbox.intersection()判断重叠的效果不符合预期。
我希望使用红/绿框的范围来判断重叠,但这些框是通过Text.set_bbox绘制的,获取其范围仍然会得到非旋转的类正方形形状,我无法找到其初始旋转原点。
此前参照相关技术社区的建议,尝试使用shapely库的Polygon对象处理,因为此前我尝试用Matplotlib的transform等相关接口实现时一直没有成功。
但我当前的实现方案复杂度较高,流程如下:
- 重缩放前存储刻度标签的旋转角度
- 获取刻度标签的
Bbox范围 - 将标签旋转角度设为0,获取其未旋转状态下的bounding box顶点
- 将原始
Bbox的角点/顶点转换为shapely库的Polygon对象,该库支持计算旋转多边形的相交区域 - 将刻度标签恢复为原始旋转角度
- 根据文本的水平/垂直对齐属性估算旋转锚点/原点,该估算结果误差较大
- 将Polygon对象围绕估算的旋转原点/锚点旋转
- 计算旋转后多边形的相交/重叠区域
- 如果没有重叠或者达到迭代上限则停止,否则根据上一次迭代的重叠面积差值等参数缩小字号,重绘图表/文本对象后回到步骤1
实现上述逻辑的代码耦合度较高,长度过长不便贴出,因此我附上制作上述演示动画的代码片段(该代码也没有正确处理旋转逻辑,可以代表当前存在的问题)。
目前我希望得到两种方案中的任意一种:要么找到获取Text对象精确旋转锚点的方法,优化上述流程的第6步;要么找到更简单的碰撞检测方案,替代当前复杂的实现。
更新:
我正在查阅Matplotlib源码,寻找旋转锚点的内部计算逻辑,看起来该逻辑在matplotlib.text.Text._get_layout()中实现(Matplotlib 3.4.3版本从388行开始),该方法中通过未旋转框的角点计算偏移量,这可能对实现有帮助,但该方法还使用了一个我还未理解含义的'baseline'参数。
示例代码如下:
#!/usr/bin/env python3 from __future__ import annotations import matplotlib as mpl import matplotlib.pyplot as plt import matplotlib.animation as animation from matplotlib.axes import Axes from matplotlib.text import Annotation from matplotlib.transforms import Bbox N_DATAPOINTS: int = 25 INITIAL_LABELSIZE: float = 10 N_FRAMES: int = 60 def calculate_overlap_area_bboxes(bbox_a: Bbox, bbox_b: Bbox) -> float: overlap_bbox: Bbox = Bbox.intersection(bbox_a, bbox_b) if overlap_bbox is None: intersecting_area = 0 else: intersecting_area: float = \ (overlap_bbox.xmax - overlap_bbox.xmin) * (overlap_bbox.ymax - overlap_bbox.ymin) return intersecting_area def labelsize_update(ii: int, ax: Axes, area_annot: Annotation, overlap_annot: Annotation, delta_fontsz: float = .25): # Get first ticklabels and their bounding boxes lbl_a, lbl_b = ax.get_xticklabels()[:2] first_bbox = lbl_a.get_window_extent().transformed(ax.transData.inverted()) second_bbox = lbl_b.get_window_extent().transformed(ax.transData.inverted()) # Check overlap overlap = calculate_overlap_area_bboxes(bbox_a=first_bbox, bbox_b=second_bbox) cur_ticklabels = ax.get_xticklabels() # There are of course more efficient ways to converge to a non-overlapping fontsize, but to keep # this example short-ish I'll simply make it a bit smaller every iteration as long as there is overlap. new_fontsize = cur_ticklabels[0].get_fontsize() - (delta_fontsz if overlap else 0) overlap_annot.set_text( '\n\n'.join([f"Visual overlap: {'YES' if new_fontsize > 5 else 'NO'}", f"Overlap according to ticklabel Bbox extents: {'YES' if overlap else 'NO'}"])) area_annot.set_text( '\n'.join([f"Fontsize {new_fontsize: .2f}", f"Overlapping label area: {overlap:.5f}", "(square data units)"])) # Update ticklabel sizes for label in cur_ticklabels: # I can color its rotated bbox for this example, but don't know how to get its coordinates? :( label.set_bbox(dict(facecolor='w', edgecolor='r' if new_fontsize > 5 else 'g', alpha=.5, pad=-.1)) label.set_fontsize(new_fontsize) if ii % N_FRAMES else label.set_fontsize(INITIAL_LABELSIZE) plt.tight_layout() return cur_ticklabels, area_annot, overlap_annot def main(): mpl.use("TkAgg") # Force tkinter backend to allow single way of fixing interactive window size for sake of this example. # Prepare initial plot and annotations fig, ax = plt.subplots(figsize=(3, 2), dpi=250) ax.plot(range(N_DATAPOINTS), [1] * N_DATAPOINTS, alpha=0) ax.set_xticks(range(N_DATAPOINTS)) ax.set_xticklabels([f"{ii} Lorem Ipsum" for ii in range(N_DATAPOINTS)], rotation=45, rotation_mode='anchor', ha='right', fontsize=INITIAL_LABELSIZE) ax.get_yaxis().set_visible(False) plt.tight_layout() area_annot = ax.annotate(f"Fontsize {INITIAL_LABELSIZE}. Overlapping label area: unknown", xy=(.05, .1), xycoords=ax.transAxes, fontsize=4, color='purple') overlap_annot = ax.annotate(f"Visual overlap: YES\n\nOverlap according to ticklabel Bbox extents: YES", xy=(.5, .5), xycoords=ax.transAxes, fontsize=5, fontweight='bold', ha='center') # Pass everything to animation and start it. _ = animation.FuncAnimation(fig, labelsize_update, fargs=[ax, area_annot, overlap_annot], interval=100, blit=False, repeat=True, frames=N_FRAMES) # Disable window resizing for the sake of this example. plt.get_current_fig_manager().window.resizable(False, False) plt.show() if __name__ == '__main__': main()
内容的提问来源于stack exchange,提问作者jrbergen

