Compare commits
1 Commits
master
...
skill/figf
| Author | SHA1 | Date |
|---|---|---|
|
|
0285059be0 |
|
|
@ -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) — 期刊样式参数与配色说明
|
||||
|
|
@ -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。
|
||||
|
|
@ -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。
|
||||
|
|
@ -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())
|
||||
|
|
@ -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"]))
|
||||
Loading…
Reference in New Issue