225 lines
8.3 KiB
Python
225 lines
8.3 KiB
Python
"""figforge:期刊级科研配图生成器。
|
||
|
||
照投稿要求的样式,把数据画成多面板的论文插图:300 DPI、克制的配色、统一字号、
|
||
去掉顶部与右侧多余坐标轴线(top/right spines)、子图带 a/b/c 面板标号——一次对齐
|
||
期刊对图的格式要求,省掉手动调 matplotlib 调到半夜。
|
||
|
||
三个核心能力:
|
||
1. 期刊样式表——一套可复用的 rcParams(字体、字号、线宽、DPI、去脊),应用后所有
|
||
图自动符合期刊规范;中文用 SimHei,避免方框乱码。
|
||
2. 多种图型——柱状图(含误差棒)、折线图(多序列)、散点图、箱线图,覆盖论文最常
|
||
用的四类。
|
||
3. 多面板组合——把多个图按行列拼成一张多面板图(Figure 1 a/b/c/d),自动加面板
|
||
标号,统一导出 300 DPI 的 PNG/PDF。
|
||
|
||
matplotlib 为可选依赖:未安装时给出清晰的安装提示而不是崩溃;图规格(panel 配置、
|
||
样式参数)的解析与校验不依赖 matplotlib,可独立测试。
|
||
|
||
用法:
|
||
python figforge.py --spec figure.json --output fig1.png
|
||
python figforge.py --demo --output demo.png # 用内置示例数据出图
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
try:
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# 期刊级配色(克制,避免蓝紫渐变等「AI 味」)
|
||
JOURNAL_PALETTE = ["#3b6ea5", "#c0504d", "#4f9d69", "#e2a829", "#7a5195", "#8c8c8c"]
|
||
|
||
# 期刊级 rcParams
|
||
JOURNAL_RC = {
|
||
"figure.dpi": 120,
|
||
"savefig.dpi": 300,
|
||
"font.size": 9,
|
||
"axes.titlesize": 10,
|
||
"axes.labelsize": 9,
|
||
"xtick.labelsize": 8,
|
||
"ytick.labelsize": 8,
|
||
"legend.fontsize": 8,
|
||
"axes.linewidth": 0.8,
|
||
"lines.linewidth": 1.5,
|
||
"axes.spines.top": False,
|
||
"axes.spines.right": False,
|
||
"axes.grid": True,
|
||
"grid.alpha": 0.3,
|
||
"grid.linewidth": 0.5,
|
||
"savefig.bbox": "tight",
|
||
}
|
||
|
||
VALID_KINDS = {"bar", "line", "scatter", "box"}
|
||
|
||
|
||
def validate_spec(spec: dict[str, Any]) -> list[str]:
|
||
"""校验图规格,返回错误列表(空表示通过)。不依赖 matplotlib。"""
|
||
errors: list[str] = []
|
||
panels = spec.get("panels")
|
||
if not isinstance(panels, list) or not panels:
|
||
errors.append("spec 需含非空的 panels 列表。")
|
||
return errors
|
||
for i, panel in enumerate(panels):
|
||
kind = panel.get("kind")
|
||
if kind not in VALID_KINDS:
|
||
errors.append(f"面板 {i + 1} 的 kind「{kind}」无效,应为 {sorted(VALID_KINDS)}。")
|
||
if "data" not in panel:
|
||
errors.append(f"面板 {i + 1} 缺少 data。")
|
||
return errors
|
||
|
||
|
||
def grid_shape(n: int, ncols: int | None = None) -> tuple[int, int]:
|
||
"""根据面板数推断行列布局。"""
|
||
if n <= 0:
|
||
return (1, 1)
|
||
if ncols:
|
||
cols = ncols
|
||
else:
|
||
cols = 1 if n == 1 else (2 if n <= 4 else 3)
|
||
rows = (n + cols - 1) // cols
|
||
return rows, cols
|
||
|
||
|
||
def _panel_label(idx: int) -> str:
|
||
return chr(ord("a") + idx)
|
||
|
||
|
||
def demo_spec() -> dict[str, Any]:
|
||
"""内置示例:四面板,覆盖四种图型。"""
|
||
return {
|
||
"title": "Figure 1",
|
||
"panels": [
|
||
{"kind": "bar", "title": "分组均值", "xlabel": "方法", "ylabel": "准确率",
|
||
"data": {"labels": ["A", "B", "C"], "values": [0.72, 0.81, 0.88],
|
||
"errors": [0.03, 0.02, 0.025]}},
|
||
{"kind": "line", "title": "训练曲线", "xlabel": "Epoch", "ylabel": "Loss",
|
||
"data": {"x": [1, 2, 3, 4, 5],
|
||
"series": {"train": [1.2, 0.8, 0.5, 0.35, 0.28],
|
||
"val": [1.3, 0.95, 0.7, 0.6, 0.58]}}},
|
||
{"kind": "scatter", "title": "相关性", "xlabel": "预测", "ylabel": "真实",
|
||
"data": {"x": [1, 2, 3, 4, 5, 6], "y": [1.1, 1.9, 3.2, 3.8, 5.1, 6.2]}},
|
||
{"kind": "box", "title": "分布对比", "xlabel": "组", "ylabel": "值",
|
||
"data": {"groups": {"对照": [12, 14, 11, 13, 15, 12],
|
||
"实验": [18, 20, 17, 19, 21, 18]}}},
|
||
],
|
||
}
|
||
|
||
|
||
def render_figure(spec: dict[str, Any], output: Path, ncols: int | None = None) -> str:
|
||
"""用 matplotlib 渲染多面板图并保存。返回保存路径。"""
|
||
try:
|
||
import matplotlib
|
||
matplotlib.use("Agg")
|
||
import matplotlib.pyplot as plt
|
||
except ImportError as exc:
|
||
raise RuntimeError(
|
||
"figforge 出图需要 matplotlib。请先安装:pip install matplotlib\n"
|
||
"(图规格校验与布局推断不需要 matplotlib,可单独使用。)"
|
||
) from exc
|
||
|
||
# 中文字体 + 期刊样式
|
||
matplotlib.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "Arial Unicode MS"]
|
||
matplotlib.rcParams["axes.unicode_minus"] = False
|
||
matplotlib.rcParams.update(JOURNAL_RC)
|
||
|
||
panels = spec["panels"]
|
||
rows, cols = grid_shape(len(panels), ncols)
|
||
fig, axes = plt.subplots(rows, cols, figsize=(cols * 3.2, rows * 2.8), squeeze=False)
|
||
flat = [axes[r][c] for r in range(rows) for c in range(cols)]
|
||
|
||
for idx, (ax, panel) in enumerate(zip(flat, panels)):
|
||
_draw_panel(ax, panel, plt)
|
||
ax.set_title(f"({_panel_label(idx)}) {panel.get('title', '')}", loc="left",
|
||
fontweight="bold")
|
||
# 多余子图隐藏
|
||
for ax in flat[len(panels):]:
|
||
ax.axis("off")
|
||
|
||
if spec.get("title"):
|
||
fig.suptitle(spec["title"], fontweight="bold")
|
||
fig.tight_layout()
|
||
output.parent.mkdir(parents=True, exist_ok=True)
|
||
fig.savefig(output)
|
||
plt.close(fig)
|
||
return str(output)
|
||
|
||
|
||
def _draw_panel(ax, panel: dict[str, Any], plt) -> None:
|
||
kind = panel["kind"]
|
||
data = panel["data"]
|
||
if kind == "bar":
|
||
labels = data["labels"]
|
||
values = data["values"]
|
||
errs = data.get("errors")
|
||
ax.bar(range(len(labels)), values, yerr=errs, capsize=3,
|
||
color=JOURNAL_PALETTE[0], edgecolor="black", linewidth=0.6)
|
||
ax.set_xticks(range(len(labels)))
|
||
ax.set_xticklabels(labels)
|
||
elif kind == "line":
|
||
x = data["x"]
|
||
for i, (name, ys) in enumerate(data["series"].items()):
|
||
ax.plot(x, ys, marker="o", markersize=3,
|
||
color=JOURNAL_PALETTE[i % len(JOURNAL_PALETTE)], label=name)
|
||
ax.legend(frameon=False)
|
||
elif kind == "scatter":
|
||
ax.scatter(data["x"], data["y"], s=18, color=JOURNAL_PALETTE[0],
|
||
edgecolor="black", linewidth=0.4, alpha=0.8)
|
||
elif kind == "box":
|
||
groups = data["groups"]
|
||
try:
|
||
ax.boxplot(list(groups.values()), tick_labels=list(groups.keys()))
|
||
except TypeError:
|
||
# 旧版 matplotlib 用 labels 参数
|
||
ax.boxplot(list(groups.values()), labels=list(groups.keys()))
|
||
ax.set_xlabel(panel.get("xlabel", ""))
|
||
ax.set_ylabel(panel.get("ylabel", ""))
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
p = argparse.ArgumentParser(prog="figforge", description="期刊级科研配图生成器")
|
||
p.add_argument("--spec", type=Path, help="图规格 JSON")
|
||
p.add_argument("--demo", action="store_true", help="用内置示例数据出图")
|
||
p.add_argument("--ncols", type=int, help="每行面板数(默认自动)")
|
||
p.add_argument("--output", type=Path, default=Path("figure.png"), help="输出图片路径")
|
||
p.add_argument("--check-only", action="store_true", help="只校验规格不出图")
|
||
args = p.parse_args(argv)
|
||
|
||
if args.demo:
|
||
spec = demo_spec()
|
||
elif args.spec and args.spec.exists():
|
||
spec = json.loads(args.spec.read_text(encoding="utf-8-sig"))
|
||
else:
|
||
print("错误:请用 --spec 提供图规格 JSON,或用 --demo 出示例图。", file=sys.stderr)
|
||
return 2
|
||
|
||
errors = validate_spec(spec)
|
||
if errors:
|
||
print("图规格校验未通过:", file=sys.stderr)
|
||
for e in errors:
|
||
print(f" - {e}", file=sys.stderr)
|
||
return 1
|
||
print(f"规格校验通过:{len(spec['panels'])} 个面板。")
|
||
|
||
if args.check_only:
|
||
return 0
|
||
try:
|
||
path = render_figure(spec, args.output, ncols=args.ncols)
|
||
except RuntimeError as exc:
|
||
print(str(exc), file=sys.stderr)
|
||
return 3
|
||
print(f"已保存 {path}(300 DPI)")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|