# -*- coding: utf-8 -*-
"""
可视化统一样式系统.
提供主题管理、配色方案、字体配置等全局样式设置,
确保所有图表风格一致。
``import hscredit`` 会自动调用 :func:`hscredit.init_setting` 建立字体和基础样式。
本模块的主题函数只在该基线上做可选覆盖。
用法::
from hscredit.core.viz import set_style, get_palette, get_font_sizes
# 在 init_setting 基线上应用可选风控主题
set_style("risk")
# 获取配色
colors = get_palette("default")
semantic = get_palette("semantic")
# 获取字体大小层级
fonts = get_font_sizes() # {'title': 14, 'subtitle': 13, ...}
"""
import copy
import platform
import matplotlib as mpl
from typing import Dict, List, Optional
# ============================================================
# 配色方案
# ============================================================
# 主色板(主题色 + 2 个副主题色)
PRIMARY_COLORS = ["#2639E9", "#F76E6C", "#FE7715"]
# 扩展色板:保留主色板顺序,其余颜色全部由两大主题色派生——
# · 蓝系 = 主题色 #2639E9 叠不同透明度白(浅→深),非通用 blue;
# · 红系 = 副主题色 #F76E6C 融合粉色 #E0249A 或主题蓝(→玫紫/粉红/危险红),非通用 red。
# 蓝、紫、粉、红渐变排布,同时相邻系列色差差距拉大,全程无黄/绿/棕分散色相。
EXTENDED_COLORS = PRIMARY_COLORS + [
"#8892F3", "#9956A4", "#EA4585", "#5665EE", "#F0587A",
"#B729AB", "#3C4DEB", "#E73B72", "#832EC2", "#BC5F8F",
"#E0249A",
]
# 语义色:蓝系基于主题色 #2639E9,红系基于副主题色 #F76E6C 融合粉/蓝
SEMANTIC_COLORS = {
"bad_rate": "#F0556F", # 副主题红融合粉,坏样本率折线
"overall_baseline": "#2639E9",
"stable": "#7884F1", # PSI < 0.1,主题蓝叠白
"changing": "#9956A4", # 0.1 <= PSI < 0.25,红融蓝→玫紫
"unstable": "#E73B72", # PSI >= 0.25,红融粉危险色
"positive": "#2639E9",
"negative": "#E04566",
"neutral": "#8A8FA3",
"reference": "#8A8FA3",
}
# 渐变色板(适合热力图/连续值):蓝系叠白、红系融粉/蓝,相邻锚点色差刻意拉开,无黄/绿
GRADIENT_PALETTES = {
# 低风险→高风险:浅蓝 → 玫紫 → 粉 → 深红,四档色相大跨度区分
"risk": ["#8892F3", "#9956A4", "#EA4585", "#E43550"],
# 主题蓝叠白(浅→深),用于「数值越大越好/越强」的顺序着色
"blue": ["#E5E7FC", "#ADB4F7", "#707CF0", "#2639E9"],
# 发散:主题蓝 → 近白 → 副主题红(融粉),用于可正可负指标
"diverging": ["#2639E9", "#F6F7FE", "#F0556F"],
# 蓝→紫→粉→红 分段类别色(红系由 #F76E6C 融粉/融蓝派生)
"pink_purple": [
"#8892F3", "#2639E9", "#832EC2", "#B729AB",
"#E0249A", "#EC4983", "#F0587A", "#E43550",
],
# 蓝→紫→粉→红 平滑连续色阶:色相单调递进(234→351°)且相邻锚点色差拉大,蓝系叠白、红系融粉,
# 全程无黄/绿,适合热力图/条件格式色阶;作为 Excel 条件格式 condition_color 锚点时自动取首/中/尾构成三色异色阶
"blue_purple_red": [
"#A4ACF6", "#2639E9", "#8C2DBE", "#B729AB",
"#E0249A", "#EC4983", "#E43550",
],
}
_PALETTES = {
"default": PRIMARY_COLORS,
"primary": PRIMARY_COLORS,
"extended": EXTENDED_COLORS,
"pink_purple": GRADIENT_PALETTES["pink_purple"],
"semantic": SEMANTIC_COLORS,
}
[文档]
def get_palette(name: str = "default"):
"""获取配色方案.
:param name: 方案名称,可选 'default'/'primary'(3色), 'extended', 'pink_purple', 'semantic'(语义色字典)
:return: 颜色列表或字典
"""
if name in _PALETTES:
return copy.deepcopy(_PALETTES[name])
if name in GRADIENT_PALETTES:
return copy.deepcopy(GRADIENT_PALETTES[name])
raise ValueError(f"未知配色方案 '{name}',可选: {list(_PALETTES.keys()) + list(GRADIENT_PALETTES.keys())}")
# ============================================================
# 字体层级
# ============================================================
_FONT_SIZES = {
"title": 14,
"subtitle": 13,
"axis_label": 12,
"tick": 10,
"legend": 10,
"annotation": 9,
"small": 8,
}
[文档]
def get_font_sizes() -> Dict[str, int]:
"""获取字体大小层级."""
return dict(_FONT_SIZES)
# ============================================================
# 默认参数
# ============================================================
_DEFAULTS = {
"dpi": 240,
"figsize": (10, 6),
"grid_alpha": 0.3,
"bar_alpha": 0.5,
"line_alpha": 0.85,
"fontweight_title": "bold",
"fontweight_label": "bold",
}
[文档]
def get_defaults() -> dict:
"""获取全局默认参数."""
return dict(_DEFAULTS)
# ============================================================
# 中文字体自动检测
# ============================================================
def _detect_cjk_fonts() -> List[str]:
"""根据操作系统检测可用的中文字体列表."""
system = platform.system()
if system == "Darwin":
candidates = ["PingFang SC", "Hiragino Sans GB", "STHeiti", "Arial Unicode MS"]
elif system == "Windows":
candidates = ["Microsoft YaHei", "SimHei", "SimSun"]
else: # Linux
candidates = ["WenQuanYi Micro Hei", "Noto Sans CJK SC", "Droid Sans Fallback"]
available = []
try:
from matplotlib.font_manager import fontManager
system_fonts = {f.name for f in fontManager.ttflist}
for font in candidates:
if font in system_fonts:
available.append(font)
except Exception:
pass
return available
# ============================================================
# 主题定义
# ============================================================
_THEMES: Dict[str, dict] = {
"risk": {
"figure.dpi": 100,
"savefig.dpi": 240,
"figure.figsize": (10, 6),
"figure.facecolor": "white",
"axes.facecolor": "white",
"axes.edgecolor": "#2639E9",
"axes.linewidth": 0.8,
"axes.grid": True,
"axes.titlesize": 14,
"axes.titleweight": "bold",
"axes.labelsize": 12,
"axes.labelweight": "bold",
"grid.alpha": 0.3,
"grid.linestyle": "--",
"legend.fontsize": 10,
"legend.frameon": False,
"xtick.labelsize": 10,
"ytick.labelsize": 10,
"lines.linewidth": 2.0,
"lines.markersize": 6,
},
"minimal": {
"figure.dpi": 100,
"savefig.dpi": 240,
"figure.figsize": (10, 6),
"figure.facecolor": "white",
"axes.facecolor": "white",
"axes.edgecolor": "#333333",
"axes.linewidth": 0.5,
"axes.grid": False,
"axes.titlesize": 13,
"axes.titleweight": "normal",
"axes.labelsize": 11,
"axes.labelweight": "normal",
"grid.alpha": 0.2,
"grid.linestyle": "-",
"legend.fontsize": 9,
"legend.frameon": False,
"xtick.labelsize": 9,
"ytick.labelsize": 9,
"lines.linewidth": 1.5,
"lines.markersize": 5,
},
"report": {
"figure.dpi": 100,
"savefig.dpi": 300,
"figure.figsize": (12, 7),
"figure.facecolor": "white",
"axes.facecolor": "#FAFAFA",
"axes.edgecolor": "#CCCCCC",
"axes.linewidth": 0.6,
"axes.grid": True,
"axes.titlesize": 15,
"axes.titleweight": "bold",
"axes.labelsize": 12,
"axes.labelweight": "bold",
"grid.alpha": 0.25,
"grid.linestyle": "--",
"legend.fontsize": 10,
"legend.frameon": True,
"xtick.labelsize": 10,
"ytick.labelsize": 10,
"lines.linewidth": 2.0,
"lines.markersize": 7,
},
}
_current_theme: Optional[str] = None
[文档]
def set_style(theme: str = "risk", chinese_font: bool = True):
"""设置全局可视化主题.
:param theme: 主题名称,可选 'risk'(默认风控主题), 'minimal'(极简), 'report'(报告用)
:param chinese_font: 是否自动配置中文字体支持
:raises ValueError: 未知主题名称
用法::
from hscredit.core.viz import set_style
set_style("risk") # 标准风控主题
set_style("report") # 报告导出主题(高DPI)
set_style("minimal") # 极简主题
"""
global _current_theme
if theme not in _THEMES:
raise ValueError(f"未知主题 '{theme}',可选: {list(_THEMES.keys())}")
# 以 hscredit.init_setting 为全局样式基线,再叠加可选主题。
from ...utils.init import init_setting
init_setting()
params = dict(_THEMES[theme])
# 中文字体
if chinese_font:
cjk_fonts = _detect_cjk_fonts()
if cjk_fonts:
params["font.sans-serif"] = cjk_fonts + ["DejaVu Sans", "Arial"]
params["axes.unicode_minus"] = False
mpl.rcParams.update(params)
_current_theme = theme
[文档]
def get_current_theme() -> Optional[str]:
"""获取当前已应用的主题名称."""
return _current_theme
[文档]
def reset_style():
"""重置为 hscredit.init_setting 定义的默认样式."""
global _current_theme
from ...utils.init import init_setting
init_setting()
_current_theme = None