Compare commits

...

1 Commits

Author SHA1 Message Date
Ct201314 0285059be0 feat(skills): add figforge academic research skill 2026-06-12 20:03:51 +08:00
5 changed files with 464 additions and 0 deletions

69
skills/figforge/SKILL.md Normal file
View File

@ -0,0 +1,69 @@
---
name: figforge
version: 1.0.0
description: "期刊级科研配图生成器照投稿要求把数据画成多面板论文插图300 DPI、克制配色、统一字号、去顶/右脊线、a/b/c 面板标号),支持柱状图(含误差棒)/折线/散点/箱线四类图型按行列拼成多面板图。当用户提到「论文配图」「期刊级图」「多面板图」「matplotlib 出图」「科研绘图」「Figure 1」时触发。"
metadata:
requires:
optional_bins: ["python"]
optional_pip: ["matplotlib"]
---
# figforge期刊级科研配图生成器
期刊图的字号、配色、排版规矩多figforge 一次给你对齐300 DPI、克制配色、去掉
顶部右侧多余轴线、子图带 a/b/c 标号,省掉手动调 matplotlib 调到半夜。
## 何时使用本技能
- 把数据画成符合投稿要求的论文图
- 多个子图拼成一张多面板图Figure 1 a/b/c/d
- 字号、配色、排版一键对齐期刊规范
## 与同类工具的区别
同类绘图技能多给一段示例代码让你自己改。本技能把**期刊样式固化成可复用的规格**
用一份 JSON 描述「要哪几个面板、各画什么数据」,工具自动套用期刊 rcParams、自动
排布局、自动加面板标号并导出 300 DPI。中文用 SimHei 避免方框乱码。matplotlib 为
可选依赖——**图规格校验与布局推断不依赖 matplotlib可独立运行**,仅最终出图需要它。
## 三个核心能力
| 能力 | 说明 |
|------|------|
| 期刊样式表 | 固化 rcParams字号/线宽/300DPI/去脊/克制配色 |
| 多种图型 | 柱状(含误差棒)/折线(多序列)/散点/箱线 |
| 多面板组合 | 多图按行列拼成 Figure自动 a/b/c 标号 |
## 工作流
```bash
# 内置示例出图(四面板,覆盖四种图型)
python scripts/figforge.py --demo --output demo.png
# 用自己的规格 JSON 出图
python scripts/figforge.py --spec figure.json --output fig1.png
# 只校验规格不出图(无需 matplotlib
python scripts/figforge.py --spec figure.json --check-only
```
规格 JSON 结构见 [references/figure-spec.md](references/figure-spec.md)。
| 参数 | 说明 |
|------|------|
| `--spec` | 图规格 JSON*或用 `--demo` |
| `--demo` | 用内置示例数据出图 |
| `--ncols` | 每行面板数(默认按面板数自动) |
| `--check-only` | 只校验规格,不调用 matplotlib |
| `--output` | 输出图片路径(.png/.pdf |
## 注意事项
- 出图需 `pip install matplotlib`;未安装时给清晰提示而非崩溃。
- 配色克制(不用蓝紫渐变等过度修饰),符合期刊审美。
- 中文需系统装有 SimHei/微软雅黑字体,否则可能回退默认字体。
## References
- [figure-spec.md](references/figure-spec.md) — 规格 JSON 结构与各图型字段
- [journal-style.md](references/journal-style.md) — 期刊样式参数与配色说明

View File

@ -0,0 +1,59 @@
# 图规格 JSON 结构
用一份 JSON 描述整张图:标题 + 若干面板。每个面板指定图型与数据。
## 顶层结构
```json
{
"title": "Figure 1",
"panels": [ {面板1}, {面板2}, ... ]
}
```
## 面板通用字段
| 字段 | 说明 |
|------|------|
| kind | 图型bar / line / scatter / box |
| title | 面板标题(会自动加 (a)(b)... 前缀) |
| xlabel / ylabel | 轴标签 |
| data | 该图型的数据(见下) |
## 各图型 data 字段
### bar柱状图可带误差棒
```json
{"labels": ["A", "B", "C"], "values": [0.72, 0.81, 0.88], "errors": [0.03, 0.02, 0.025]}
```
`errors` 可省略。
### line折线图支持多序列
```json
{"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]}}
```
### scatter散点图
```json
{"x": [1,2,3,4,5,6], "y": [1.1,1.9,3.2,3.8,5.1,6.2]}
```
### box箱线图
```json
{"groups": {"对照": [12,14,11,13], "实验": [18,20,17,19]}}
```
## 布局
面板数 → 默认列数1 列1 个、2 列2-4 个、3 列5+ 个)。可用 `--ncols` 覆盖。
多余的格子自动隐藏。
## 校验
`--check-only` 只校验规格不出图,不需要 matplotlib。校验项panels 非空、每个面板
kind 合法、含 data。

View File

@ -0,0 +1,49 @@
# 期刊样式参数与配色
figforge 固化了一套贴近期刊投稿要求的样式,应用后所有图自动符合规范。
## 关键 rcParams
| 参数 | 值 | 理由 |
|------|----|----|
| savefig.dpi | 300 | 期刊印刷要求的分辨率 |
| font.size | 9 | 论文图常用正文字号 |
| axes.titlesize | 10 | 面板标题略大于正文 |
| axes.spines.top/right | False | 去掉顶部右侧多余轴线,更清爽 |
| axes.grid + grid.alpha | True, 0.3 | 浅网格辅助读数,不喧宾夺主 |
| lines.linewidth | 1.5 | 线条清晰但不笨重 |
| savefig.bbox | tight | 去掉多余白边 |
## 配色
```
#3b6ea5#c0504d#4f9d69 绿
#e2a829#7a5195#8c8c8c
```
刻意选用克制的低饱和度配色避免蓝紫渐变、亮黄、彩虹色等过度修饰的「AI 味」配色,
符合学术审美,黑白打印也能区分。
## 中文字体
```
font.sans-serif = ["SimHei", "Microsoft YaHei", "Arial Unicode MS"]
axes.unicode_minus = False
```
直接配置 rcParams 而非套用 SciencePlots 等样式包,避免中文与样式包字体设置冲突
导致方框乱码(这是科研绘图的常见坑)。
## 面板标号
每个子图标题自动加 `(a)`、`(b)`、`(c)`…前缀,左对齐加粗,符合 Nature 等期刊
对多面板图的标注惯例。
## 导出
PNG位图投稿/预览通用)或 PDF矢量印刷最佳均 300 DPI、紧凑边距。
## 局限
- 复杂的组合图(如双 y 轴、嵌入子图、热力图)当前未覆盖,可在 `_draw_panel` 扩展。
- 配色与字号面向通用期刊;个别期刊有特殊要求时需按其指南微调 rcParams。

View File

@ -0,0 +1,224 @@
"""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())

View File

@ -0,0 +1,63 @@
"""figforge 单元测试。聚焦不依赖 matplotlib 的规格校验与布局逻辑。"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import pytest
from figforge import validate_spec, grid_shape, demo_spec, _panel_label, VALID_KINDS
class TestValidateSpec:
def test_valid(self):
spec = {"panels": [{"kind": "bar", "data": {"labels": ["a"], "values": [1]}}]}
assert validate_spec(spec) == []
def test_empty_panels(self):
assert validate_spec({"panels": []})
def test_no_panels_key(self):
assert validate_spec({})
def test_invalid_kind(self):
errs = validate_spec({"panels": [{"kind": "pie3d", "data": {}}]})
assert any("无效" in e for e in errs)
def test_missing_data(self):
errs = validate_spec({"panels": [{"kind": "bar"}]})
assert any("缺少 data" in e for e in errs)
class TestGridShape:
def test_single(self):
assert grid_shape(1) == (1, 1)
def test_two(self):
assert grid_shape(2) == (1, 2)
def test_four(self):
assert grid_shape(4) == (2, 2)
def test_five_uses_three_cols(self):
assert grid_shape(5) == (2, 3)
def test_custom_ncols(self):
assert grid_shape(6, ncols=2) == (3, 2)
class TestDemoSpec:
def test_demo_valid(self):
assert validate_spec(demo_spec()) == []
def test_demo_covers_all_kinds(self):
kinds = {p["kind"] for p in demo_spec()["panels"]}
assert kinds == VALID_KINDS
class TestPanelLabel:
def test_labels(self):
assert _panel_label(0) == "a"
assert _panel_label(3) == "d"
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))