figforge/scripts/figforge.py

225 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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())