yuzihan@SIM1
Deploy SRHarness Docker Space
d06fa57
Raw History Blame Contribute Delete
16.1 kB
# Copyright (c) 2024-present, Yumeow. Licensed under the MIT License.
import os
import re
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
from typing import List, Dict, Literal, Tuple
def get_fig(RN, CN, FW=None, FH=None, AW=None, AH=None, A_ratio=1.0,
LM=3, RM=3, TM=3, BM=3, HS=None, VS=None, dpi=300,
fontsize=7, lw=0.5, gridspec=False, **kwargs):
# 基础字体和尺寸
plt.rcParams['font.family'] = kwargs.get('font_family', 'Arial')
plt.rcParams['font.size'] = fontsize
# --- 字体大小设置 (Font Sizes) ---
plt.rcParams['axes.labelsize'] = fontsize
plt.rcParams['axes.titlesize'] = fontsize
plt.rcParams['xtick.labelsize'] = fontsize * (5/7)
plt.rcParams['ytick.labelsize'] = fontsize * (5/7)
plt.rcParams['legend.fontsize'] = fontsize * (5/7)
plt.rcParams['figure.titlesize'] = fontsize
# --- 线条宽度设置 (Line Widths) ---
plt.rcParams['lines.linewidth'] = lw
plt.rcParams['axes.linewidth'] = lw
plt.rcParams['xtick.major.width'] = lw
plt.rcParams['ytick.major.width'] = lw
plt.rcParams['xtick.minor.width'] = lw
plt.rcParams['ytick.minor.width'] = lw
plt.rcParams['grid.linewidth'] = lw
plt.rcParams['patch.linewidth'] = lw
# --- 导出设置 (Export Settings) ---
plt.rcParams['pdf.fonttype'] = 42
plt.rcParams['svg.fonttype'] = 'none'
LM = LM * fontsize / 72
RM = RM * fontsize / 72
TM = TM * fontsize / 72
BM = BM * fontsize / 72
HS = HS * fontsize / 72 if HS is not None else LM
VS = VS * fontsize / 72 if VS is not None else TM
if AW is not None:
AW /= 2.54
FW = LM + CN * AW + (CN - 1) * HS + RM
if AH is not None:
AH /= 2.54
FH = TM + RN * AH + (RN - 1) * VS + BM
A_ratio = AW / AH
else:
AH = AW / A_ratio
FH = TM + RN * AH + (RN - 1) * VS + BM
elif AH is not None:
AH /= 2.54
AW = AH * A_ratio
FH = TM + RN * AH + (RN - 1) * VS + BM
FW = LM + CN * AW + (CN - 1) * HS + RM
elif FW is not None:
FW /= 2.54
AW = (FW - LM - RM - (CN - 1) * HS) / CN
if FH is not None:
FH /= 2.54
AH = (FH - TM - BM - (RN - 1) * VS) / RN
A_ratio = AW / AH
else:
AH = AW / A_ratio
FH = TM + RN * AH + (RN - 1) * VS + BM
elif FH is not None:
FH /= 2.54
AH = (FH - TM - BM - (RN - 1) * VS) / RN
AW = AH * A_ratio
FW = LM + CN * AW + (CN - 1) * HS + RM
figinfo = dict(RN=RN, CN=CN, FW=FW, FH=FH, AW=AW, AH=AH,
LM=LM, RM=RM, TM=TM, BM=BM, HS=HS, VS=VS,
r_AW=AW/FW, r_AH=AH/FH, r_HS=HS/FW, r_VS=VS/FH,
r_LM=LM/FW, r_RM=RM/FW, r_TM=TM/FH, r_BM=BM/FH,
fontsize=fontsize, lw=lw,
top_box=(LM/FW, 1-TM/FH, 1-(LM+RM)/FW, TM/FH),
bottom_box=(LM/FW, 0, 1-(LM+RM)/FW, BM/FH),
right_box=(1-RM/FW, BM/FH, RM/FW, 1-(TM+BM)/FH),
left_box=(0, BM/FH, LM/FW, 1-(TM+BM)/FH),
)
if gridspec:
fig = plt.figure(figsize=(FW, FH), dpi=dpi, **kwargs)
gs = fig.add_gridspec(RN, CN, wspace=HS/AW, hspace=VS/AH, left=LM/FW, right=1-RM/FW, top=1-TM/FH, bottom=BM/FH)
return figinfo, fig, gs
else:
fig, axes = plt.subplots(RN, CN, figsize=(FW, FH), dpi=dpi, **kwargs)
fig.subplots_adjust(left=LM/FW, right=1-RM/FW, top=1-TM/FH, bottom=BM/FH, wspace=HS/AW, hspace=VS/AH)
axes = axes.ravel() if RN * CN > 1 else [axes]
return figinfo, fig, axes
def plot_resilience(f:callable, extent=(0, 1, 0, 1), gridnum=(1000, 1000), cmap=None, norm=None, ax=None, reset_xylim=True, lw=1):
"""
绘制二维函数的韧性
- f: 二维函数,如 lambda x, y: y - 3*y**2 - y**3 + x*y**3
- extent: 函数定义域 (xmin, xmax, ymin, ymax)
- gridnum: 网格数量 (xnum, ynum)
---
Example:
>>> f = lambda x, y: y - 3*y**2 - y**3 + x*y**3
>>> plot_resilience(f, ax=axes[0], extent=(0, 5, 0, 5))
>>> f = lambda x, y: np.sin(np.sqrt(x**2 + y ** 2))
>>> plot_resilience(f, ax=axes[1], extent=(-4*np.pi, 4*np.pi, -4*np.pi, 4*np.pi))
>>> f = lambda x, y: np.sin(np.sqrt(x ** 2 + y ** 2)) * x - y * np.cos(np.sqrt(x ** 2 + y ** 2))
>>> plot_resilience(f, ax=axes[2], extent=(-4*np.pi, 4*np.pi, -4*np.pi, 4*np.pi))
"""
if cmap is None: cmap = mcolors.LinearSegmentedColormap.from_list('my_bwr', ['#74b9ff', 'white', '#ff7675'])
if norm is None: norm = mcolors.SymLogNorm(linthresh=0.01, linscale=0.01, vmin=-1, vmax=1)
if ax is None: ax = plt.gca()
def plot1(x, y):
ax.plot(x, y, 'black', linewidth=lw, linestyle='-', solid_capstyle='round')
def plot2(x, y):
ax.plot(x, y, 'gray', linewidth=lw, linestyle=(3, (3, 1)), solid_capstyle='round')
xmin, xmax, ymin, ymax = extent
xnum, ynum = gridnum
x = np.linspace(xmin, xmax, xnum)
y = np.linspace(ymin, ymax, ynum)
x, y = np.meshgrid(x, y)
z = f(x, y)
ax.imshow(z, origin='lower', aspect='auto', cmap=cmap, norm=norm, zorder=0, extent=(xmin, xmax, ymin, ymax))
contours = ax.contour(x, y, z, levels=[0], linewidths=0)
for coll in contours.collections: coll.remove()
dzdy = np.gradient(z)[0]
for coll in contours.collections:
for path in coll.get_paths():
saved = []
sign = None
for (x0, y0) in path.vertices:
saved.append((x0, y0))
x_idx = int(np.floor((x0-xmin) / (xmax-xmin) * (xnum - 1)))
y_idx = int(np.floor((y0-ymin) / (ymax-ymin) * (ynum - 1)))
if sign is None: sign = np.sign(dzdy[y_idx, x_idx])
if np.sign(dzdy[y_idx, x_idx] * sign) < 0:
if sign < 0: plot1(*np.array(saved).T)
else: plot2(*np.array(saved).T)
sign *= -1
saved = []
else:
pass
if saved:
if sign < 0: plot1(*np.array(saved).T)
else: plot2(*np.array(saved).T)
if reset_xylim:
scale = lambda min, max, ratio=0.1: (min - (max - min) * ratio / 2, max + (max - min) * ratio / 2)
ax.set_xlim(*scale(xmin, xmax))
ax.set_ylim(*scale(ymin, ymax))
class EqualizeNormalize(mcolors.Normalize):
""" 按分布而非值进行归一化 """
def __init__(self, samples, clip=False):
super().__init__(vmin=samples.min(), vmax=samples.max(), clip=clip)
hist, bin_edges = np.histogram(samples.flatten(), bins=256, range=(self.vmin, self.vmax), density=True)
cdf = hist.cumsum()
cdf = (cdf - cdf.min()) / (cdf.max() - cdf.min())
self.bin_edges = bin_edges
self.cdf = cdf
def __call__(self, value, clip=False):
value = np.array(value)
return np.ma.masked_array(np.interp(value.flatten(), self.bin_edges[:-1], self.cdf).reshape(value.shape))
def inverse(self, value):
value = np.array(value)
return np.ma.masked_array(np.interp(value.flatten(), self.cdf, self.bin_edges[:-1]).reshape(value.shape))
def plotOD(ax, source:List[str], destination:List[str], flow:List[float], location:Dict[str, Tuple[float, float]],
linetype:Literal['straight', 'parabola', 'rotated_parabola', 'projected_parabola']='straight', N=100, zorder=10,
**kwargs):
""" 绘制OD流量 """
cmap = mcolors.LinearSegmentedColormap.from_list('cmap', ['#0308F8', '#FD0B1B', '#ffff00'], gamma=5.0)
norm = EqualizeNormalize(flow.values)
t = np.linspace(0, 1, N)
ignored = set(list(source) + list(destination)) - set(location.keys())
if ignored: print(f'\033[33mWarning: {ignored} are not in location\033[0m')
for (s, d, f) in zip(source, destination, flow):
if s not in location or d not in location: continue
p1 = np.array(location[s])[:, np.newaxis] # (2, 1)
p2 = np.array(location[d])[:, np.newaxis] # (2, 1)
if linetype == 'straight':
ax.plot(*(p1 * (1 - t) + p2 * t), lw=0.05+0.1*norm(f), alpha=0.4+0.4*norm(f), color=cmap(norm(f)), zorder=zorder+norm(f), rasterized=kwargs.get('rasterized', False))
elif linetype == 'parabola':
y_scale = locals().get('y_scale', np.diff(ax.get_ylim())[0])
xy = p1 * (1-t) + p2 * t + np.array([[0], [1]]) * 4 * t * (1-t) * norm(f) * y_scale * kwargs.get('scale', 0.1)
ax.plot(*xy, lw=0.05+0.1*norm(f), alpha=0.4+0.4*norm(f), color=cmap(norm(f)), zorder=zorder+norm(f), rasterized=kwargs.get('rasterized', False))
elif linetype == 'rotated_parabola':
height = 0.5 * norm(f)
C, S = (p2 - p1)[:, 0]
A = np.array([[C, -S], [S, C]]) / 2
if kwargs.get('adjust_up', None) and (C < 0): A = -A
if kwargs.get('adjust_down', None) and (C > 0): A = -A
xy = A @ np.array([2 * t - 1, height * 4 * t * (1 - t)]) + 0.5 * (p1 + p2)
ax.plot(*xy, lw=0.05+0.1*norm(f), alpha=0.4+0.4*norm(f), color=cmap(norm(f)), zorder=zorder+norm(f), rasterized=kwargs.get('rasterized', False))
elif linetype == 'projected_parabola':
p0 = locals().get('p0', np.array(kwargs.get('p0', [np.mean(ax.get_xlim()), np.mean(ax.get_ylim())]))[:, np.newaxis])
D = kwargs.get('D', 10)
xy = p0 + D * (p1 * (1-t) + p2 * t - p0) / (D - 4*t*(1 - t)*(norm(f) + 1))
ax.plot(*xy, lw=0.05+0.1*norm(f), alpha=0.4+0.4*norm(f), color=cmap(norm(f)), zorder=zorder+norm(f), rasterized=kwargs.get('rasterized', False))
else:
raise ValueError(f'Invalid linetype: {linetype}')
return ax
def clear_svg(path, debug=False):
"""
matplotlib 生成的 svg 中会使用 <text style="font: 9.8px 'Arial'; text-anchor: middle" x="80.307802" y="193.900483">2020-02-02</text> 的语法,而 Powerpoint 无法识别 font: 9.8px 'Arial'; 的简写记法,只能识别 font-family: 'Arial'; font-size: 9.8px; 的记法。因此需要进行转换。考虑的属性包括:
- font-size
- font-family
- font-weight
- font-style
"""
raise DeprecationWarning("现在好像不需要 clear 了,直接导入 PPT 即可")
from lxml import etree
tree = etree.parse(path)
root = tree.getroot()
for text in root.findall('.//{http://www.w3.org/2000/svg}tspan') + root.findall('.//{http://www.w3.org/2000/svg}text'):
style = text.attrib.pop('style', '')
font_size = re.search(r'font:[^;]*\s+(\d+\.?\d*px)(?:\s|$)', style)
font_family = re.search(r'font:[^;]*\s+\'([^\']*)\'(?:\s|$)', style)
font_style = re.search(r'font:[^;]*\s+(italic|oblique)(?:\s|$)', style)
font_weight = re.search(r'font:[^;]*\s+(bold|normal|bolder|lighter)(?:\s|$)', style)
line_height = re.search(r'line-height:\s+([\d\.]+(px|em|%)?)', style)
font_variant = re.search(r'font-variant:\s+([\w-]+)', style)
text_anchor = re.search(r'text-anchor:\s+([\w-]+)', style)
if font_size: text.set('font-size', font_size.group(1))
if font_family: text.set('font-family', font_family.group(1))
if font_style: text.set('font-style', font_style.group(1))
if font_weight: text.set('font-weight', font_weight.group(1))
if line_height: text.set('line-height', line_height.group(1))
if font_variant: text.set('font-variant', font_variant.group(1))
if text_anchor: text.set('text-anchor', text_anchor.group(1))
if debug: print(f'"{style}" -> "{text.attrib}"')
tree.write(path)
def load_font():
"""
- plt.title("示例图表:数字平方", fontproperties=font, size=15)
- plt.xlabel("数字", fontproperties=font, size=12)
- plt.ylabel("平方", fontproperties=font, size=12)
"""
import requests
from matplotlib.font_manager import FontProperties
url = "https://ghp.ci/https://github.com/notofonts/noto-cjk/blob/main/Sans/SubsetOTF/SC/NotoSansSC-Regular.otf"
font_path = "/tmp/SimHei.otf"
if not os.path.exists(font_path):
response = requests.get(url)
if response.status_code == 200:
with open(font_path, "wb") as f:
f.write(response.content)
else:
raise Exception("Failed to download the font.")
font = FontProperties(fname=font_path)
plt.rcParams["font.family"] = "sans-serif"
plt.rcParams["font.sans-serif"] = [font_path] # 这里指定.otf文件路径
plt.rcParams["axes.unicode_minus"] = False # 正确显示负号
return font
# 计算两个 box 的重叠面积
def _overlapping_area(bbox1, bbox2):
x0 = max(bbox1.x0, bbox2.x0)
x1 = min(bbox1.x1, bbox2.x1)
y0 = max(bbox1.y0, bbox2.y0)
y1 = min(bbox1.y1, bbox2.y1)
return max(0, x1 - x0) * max(0, y1 - y0)
# 计算 point 到 box 的最近距离
def _distance_to_box(point, box):
x = max(box.x0, min(point[0], box.x1))
y = max(box.y0, min(point[1], box.y1))
return np.sqrt((point[0] - x) ** 2 + (point[1] - y) ** 2)
# 调整文本位置的函数
def adjust_text(texts, ax, step=0.01, max_iterations=100, mode="xy"):
raw_pos = np.array([text.get_position() for text in texts], dtype=float)
cur_pos = raw_pos.copy()
overlap = np.zeros((len(texts), len(texts)))
def update(i, utri_only=False):
bbox1 = texts[i].get_window_extent(renderer=ax.figure.canvas.get_renderer())
for j, text2 in enumerate(texts):
if j == i:
continue
if utri_only and j <= i:
continue
bbox2 = text2.get_window_extent(renderer=ax.figure.canvas.get_renderer())
overlap[i, j] = overlap[j, i] = _overlapping_area(bbox1, bbox2)
def normalize(v):
return v / np.linalg.norm(v).clip(1e-6)
# 初始化
for i in range(len(texts)):
update(i, utri_only=True)
for iteration in range(max_iterations):
i, j = random.choice(list(np.stack(np.nonzero(overlap), axis=-1)))
# i 对 j 的排斥
F1 = cur_pos[j] - cur_pos[i]
# raw_pos[j] 对 j 的吸引
bbox = texts[j].get_window_extent(renderer=ax.figure.canvas.get_renderer())
F2 = (
normalize(raw_pos[j] - cur_pos[j])
* _distance_to_box(raw_pos[j], bbox)
* 0.001
)
# F2 = 0.0
# 随机扰动
F3 = np.random.randn(2)
# 合力
F = F1 + F2 + F3
if mode == "x":
F[1] = 0
if mode == "y":
F[0] = 0
# 更新位置
cur_pos[j] += F * step
texts[j].set_position(cur_pos[j])
update(j)
if (overlap == 0).all():
break
def use_chinese_font(fontpath="/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"):
font_prop = FontProperties(fname=fontpath)
rcParams["font.family"] = font_prop.get_name()
rcParams["axes.unicode_minus"] = False
def merge_axes(axes, fig=None):
if fig is None: fig = axes[0].figure
xmin = min(ax.get_position().bounds[0] for ax in axes) # axes[0, 2] 的左上角坐标
xmax = max(ax.get_position().bounds[0] + ax.get_position().bounds[2] for ax in axes) # axes[1, 3] 的右下角坐标
ymin = min(ax.get_position().bounds[1] for ax in axes) # axes[0, 2] 的左上角坐标
ymax = max(ax.get_position().bounds[1] + ax.get_position().bounds[3] for ax in axes) # axes[1, 3] 的右下角坐标
merged_ax = fig.figure.add_axes([xmin, ymin, xmax - xmin, ymax - ymin])
# 如果 axes[0] 被设置与其他的 ax 共享坐标轴,则需要设置 merged_ax 也共享坐标轴
# if axes[0].get_shared_x_axes() is not None:
# merged_ax.get_shared_x_axes().join(merged_ax, *axes)
# if axes[0].get_shared_y_axes() is not None:
# merged_ax.get_shared_y_axes().join(merged_ax, *axes)
for ax in axes:
fig.delaxes(ax)
return merged_ax