论文里的图变成可交互 ECharts——图表的可交互
把论文里的图变成可交互 ECharts
14 个 Agent 的最后一项,原本是工作量最大的一项。这周把后端到前端的整条链路搭完,vision key 还没申请下来,所以暂时跑在 placeholder 分支上——但这条 placeholder 路径本身也是有意为之的设计。
起因:把最后一项收掉
翻 week4 末尾下周要做的,第一条就是"推图表智能转化。这周做完,大部分 Agent 到位。
为啥这一项排到最后才做。原因有两层:第一是工作量最大;第二是依赖外部 vision API——而我们的 API key 申请到现在还没下来。所以这周的策略是:vision key 不在也不耽误把代码骨架搭完——key 来了立刻能切到真识别,key 不来时 placeholder 路径也能跑通整套 demo 流程。这种"拆掉外部依赖也能演示"的写法是第三次复用了——SP9 的 Cohere Rerank 是这么做的,SP10 的 pgvector 服务也是这么做的,到这一项已经是肌肉记忆。
主链路其实并不复杂:用户上传 PDF → 后端按页抽出嵌入的图 → 渲染成 PNG → base64 编码 → vision API 识别(“这是个柱状图,x 轴是模型大小,y 轴是 latency”)→ 解析返回的 JSON 拼成 ECharts 配置 → 前端渲染成可交互图表。听起来一气呵成,但每一步都有自己的坑。
一、PyMuPDF 抽图:bbox + zoom + CMYK 转换
我们已经有 PyMuPDF 在做 PDF 文本解析(week1 写的),抽图只是它的另一个用法。page.get_images() 返回页面上所有嵌入图的 xref 列表,page.get_image_rects(xref) 拿到该图在页面上的 bbox:
for img in page.get_images(full=True):
xref = img[0]
bbox_list = page.get_image_rects(xref)
if not bbox_list:
continue
bbox = bbox_list[0]
width = bbox.x1 - bbox.x0
height = bbox.y1 - bbox.y0
if width < min_size or height < min_size:
continue # 跳过 logo / icon
min_size=200 这个阈值是跑了几篇论文之后定的——绝大部分论文头部有 conference logo(约 80×40px)、底部有 affiliation logo(约 100×100px),这些图直接送 vision API 就是浪费一次调用解析"这是 USENIX Security 的会标"。卡 200 阈值能干净地过滤掉 logo 类,留下来的几乎全是真正的数据图。
bbox 拿到之后,渲染成 PNG。关键是用 clip=bbox 让 pixmap 只渲染图所在区域,不要整页:
matrix = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=matrix, clip=bbox)
zoom=2.0 大约对应 192 DPI,给 vision 模型够清楚——再高就是浪费 token,再低 vision 模型读不出折线上的小数字。
然后是 CMYK 这个坑。某些论文里嵌的图是 CMYK 色彩空间,如果直接 pix.tobytes("png") 会颜色完全错乱青色变橙色只类。判断 + 转换一行解决:
if pix.n - pix.alpha >= 4:
pix = fitz.Pixmap(fitz.csRGB, pix)
png_bytes = pix.tobytes("png")
b64 = base64.b64encode(png_bytes).decode("ascii")
pix.n - pix.alpha 是颜色通道数,RGB 是 3,CMYK 是 4,所以 >= 4 一定是 CMYK 或更深格式。这条踩坑前我跑了两篇论文都是一切正常,第三篇某个图突然全紫色——原来是 CMYK。文档查到这条之后才稳定。
base64 编码完直接塞进 image_b64 字段返回给上层,最大 5 张图,避免一次会话调 vision API 太多次。
二、Vision API:选 OpenAI 兼容协议
vision 模型市场现在百花齐放——Kimi、Gemini、Qwen-VL、智谱 GLM-4V、阶跃星辰各家都有自己的 SDK。但 OpenAI 的 Chat Completions 那套 messages + image_url + text 的请求格式已经成了事实标准,几乎所有家都给了 OpenAI 兼容层。我们只需要三个环境变量:
VISION_API_KEY=...
VISION_API_BASE=https://api.moonshot.cn/v1 # Kimi
VISION_MODEL=moonshot-v1-32k-vision-preview
代码用 httpx 直接打:
url = f"{self._api_base}/chat/completions"
payload = {
"model": self._model,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{image['image_b64']}"}},
],
}],
"max_tokens": 1500,
"temperature": 0.2,
}
temperature=0.2 是为了让模型输出 JSON 更稳定,避免它在 prompt 里夹一段"这是个有趣的图…"这种话。max_tokens=1500 给 ECharts option 够了——大部分图就 5-10 个数据点,JSON 不会很长。
key 一换、API base 一换,这份代码就能跑别家。不绑定单一 vendor 是这次设计最值得的决定——下个月哪家 vision 模型出了便宜版本,我们换一个环境变量就能切。
图:PDF 经 PyMuPDF 抽 bbox → 渲染 PNG → base64 → vision API → JSON 解析 → ChartResult 输出。两条红色虚线是兜底——KEY 缺/5xx 走 make_placeholder(保留原图);JSON 非法走正则提取 {...}。两路最终都汇入同一个 ChartResult 结构,前端拿到的数据形状始终一致。
三、JSON 不能信,正则兜底
prompt 里写得很清楚——“只输出一个 JSON 对象,不要 markdown 代码块外壳”。但模型该违反还是违反。三种返回我都见过:
情况 1(理想):
{"kind":"bar","title":"...","echarts_option":{...}}
情况 2(一半概率):
{"kind":"line","title":"...","echarts_option":{...}}
情况 3(10% 概率):
对不起,我无法识别这张图。
正则兜底分两步——先看有没有 ```json ````fenced 块,没有就退回找最外层的{和}`:
@staticmethod
def _extract_json_block(text: str) -> str | None:
if not text:
return None
# 优先取 ```json ... ```块
m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if m:
return m.group(1)
# 否则裸 {...}
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1 or end <= start:
return None
return text[start : end + 1]
提取失败就标 kind=unknown,但保留原图 base64——前端还能展示,用户能看到"这张图模型没识别但你自己看"。这点是关键——AI 摆烂的时候至少让用户看到原图,比抛 异常体面得多。
四、SSE marker 块协议:不改主协议把 JSON 塞过去
这是这次设计上最纠结的一处。我们的 SSE 协议只支持四种事件:node_start / message / node_end / message_end。新加一种 chart_results 事件意味着前后端都要改解析逻辑。
但 chart_results 数据量大——5 张图加起来 base64 PNG 可能 200KB+,又不希望它分多 chunk 流式发,它是个原子单元,前端要么全拿到要么全没有。新增 SSE event type 还要改 client.ts、useSSE.ts、ChatPanel.vue 一连串。
最后的方案:节点在 agent_response 末尾追加一个 marker 块,前端在 onDone 时整体解析。
解析后的结果格式:
## 图表智能转化
图 1(第 3 页 · 类型:bar)— acc 对比图
...
<!--CHART_RESULTS_BEGIN-->
[{"page":3,"kind":"bar","echarts_option":{...}, "image_b64":"...", ...}]
<!--CHART_RESULTS_END-->

后端在 agent_response 末尾追加 marker 块;前端 ChatPanel.onDone 调 extractChartResults 把 JSON 解析出来推到 message.charts 字段,并把 marker 从 content 抽走,渲染时不进 markdown。一条协议外的"夹带",前后端独立演进。(流程图是ai参考我们的流程绘制的,提前使用了一下kimi尝试效果)
前端代码新增的事情很轻:
// frontend/src/types/chart.ts
export function extractChartResults(text: string): {
charts: ChartResult[]
cleaned: string
} {
const begin = text.lastIndexOf(CHART_MARKER_BEGIN)
const end = text.lastIndexOf(CHART_MARKER_END)
if (begin === -1 || end === -1 || end <= begin) {
return { charts: [], cleaned: text }
}
const jsonStr = text.slice(begin + CHART_MARKER_BEGIN.length, end).trim()
let charts: ChartResult[] = []
try {
const parsed = JSON.parse(jsonStr) as unknown
if (Array.isArray(parsed)) charts = parsed as ChartResult[]
} catch {
return { charts: [], cleaned: text }
}
const cleaned = text.slice(0, begin) + text.slice(end + CHART_MARKER_END.length)
return { charts, cleaned: cleaned.trimEnd() }
}
chat store 在 onDone 时调一次:
function finalizeLastAssistant(): void {
const last = messages.value[messages.value.length - 1]
if (!last || last.role !== 'assistant') return
const { charts, cleaned } = extractChartResults(last.content)
if (charts.length > 0) {
last.charts = charts
last.content = cleaned // marker 块从可见内容里抽走,不进 markdown 渲染
}
}
这种协议外的"夹带"这次用下来感觉很好——主 SSE 协议一行不动,只在内容末尾塞一段带 marker 的纯文本,老的 SSE 客户端,不认 marker 的也照样能跑,只是 marker 会显示在 markdown 里看着丑,但不崩溃。前后端独立演进——后端想换协议直接改 marker 字符串、前端跟着改解析;不需要协调上线。
五、前端:ChartGallery 双列对比布局
前端新加的组件 ChartGallery.vue,每张图一行——左边显示原图(直接 <img :src="data:image/png;base64,...">),右边 mount 一个 echarts 实例渲染 vision 模型给的 ECharts option:
<div class="chart-cells">
<div class="chart-cell">
<div class="chart-cell-label">原图</div>
<img :src="`data:image/png;base64,${c.image_b64}`" class="chart-img" />
</div>
<div class="chart-cell">
<div class="chart-cell-label">ECharts 重绘</div>
<div :ref="(el) => setChartRef(el as HTMLDivElement, idx)" class="chart-canvas" />
</div>
</div>
echarts 没用 vue-echarts wrapper,直接 import * as echarts from 'echarts' + echarts.init(div).setOption(opt),简单稳定。onBeforeUnmount 时 inst.dispose() 一遍避免内存泄漏。
最坑的一处是 setOption 在 option 字段缺失时会抛——比如模型偷懒只给了 {"series": []} 没 xAxis,echarts 直接报错。包一层 try/catch 即可:
try {
inst.setOption(opt as echarts.EChartsOption, true)
inst.resize()
} catch (err) {
console.warn(`[ChartGallery] setOption failed @${idx}:`, err)
}
失败时左边原图照常显示,右边那个 div 留空——视觉上能看出这张 AI 没识别好。
六、placeholder 路径:vision key 没下来时怎么办
VISION_API_KEY 缺失或者 API 调用失败时,service 的 enabled=False,节点为每张图返一条 placeholder:
@staticmethod
def make_placeholder(image: ChartImage, reason: str) -> ChartResult:
return ChartResult(
page=image.get("page", 0),
bbox=image.get("bbox", []),
image_b64=image.get("image_b64", ""), # 原图保留
kind="unknown",
title="未识别",
description=reason,
echarts_option={
"title": {"text": reason, "left": "center", "top": "middle"},
"series": [],
},
error=reason,
)
前端拿到这条 placeholder 之后照常渲染——左边原图正常显示,右边 echarts 区域显示一个写着"未配置 VISION_API_KEY"的居中文字。整个 UI 流程不崩、用户能看到原图、能感知到"哦这块功能依赖 vision,目前在 fallback 模式"。
agent_response 顶部还会加一行警示:
> 当前未配置 VISION_API_KEY,仅展示原图占位。配置后可实时识别 → ECharts。
七、踩过的坑
这里依旧写一下碰到的问题,这周原本还要在做一些东西,但是期中检测,就再写一篇报告作为一个检查吧,本来就有四篇应该也是够的,下周把周末补的内容再多说一下,目前已经完成图表识别了。
page.get_image_rects(xref)偶尔返空——某些 PDF 的图是嵌在矢量绘图块里没有独立的 xref,这种就跳过,不抢救。pix.n - pix.alpha >= 4判 CMYK 那条要查文档才知道,跑一次出现紫色错色才意识到。- vue-tsc 在 ref callback 写法上偶尔不稳——
(el: HTMLDivElement | null) => void直接写编译报错,断言成el as HTMLDivElement | null才稳定通过。 vitest默认能扫到__tests__/子目录的 spec 文件不需要专门配 testMatch——但要把 spec 放在frontend/src/types/__tests__/里,不能放在根 tests 下。echarts.init(div)调用前div必须有非零的 width/height,不然渲染出空白。CSS 上 chart-canvas 直接给min-height: 280px兜底。- httpx 默认 timeout 是 5 秒——vision API 处理一张图通常 5-10 秒,必须显式
httpx.post(..., timeout=30.0)。
八、测试矩阵
两件事加一起 17 个新单测,全 fake,不连真 vision API / PyMuPDF 大文件解析:
chart_service:disabled flag、placeholder 内容、analyze fallback、JSON 裸 / fenced / garbage / 非法 4 种解析路径、real PDF 抽图(papers/local_store/pdfs 里有就跑,没有则 skip)(10 个)chart_extractor_node:service None / 无 PDF / 抽图抛错 / 0 张图 / disabled service / enabled service 调 analyze / response marker 合法 JSON(7 个)
跑 pytest tests/test_chart_service.py tests/test_chart_extractor_node.py -v 全绿,约 4 秒。整套加上前面 12 周累积 79 个单测全绿,回归没破。
前端 vitest 加一组 4 个 case 覆盖 extractChartResults 解析(空 / 正常 / JSON 非法 / 半截 marker),跑通过。
九、下周要做的
- vision API key 申请下来后立刻切真识别,跑一组 happy / sad case 看 echarts_option 质量
- ChartGallery 加"导出 ECharts JSON"按钮,方便用户拿配置去自己的 dashboard
- 探索"图与文段关联"——识别完图之后看正文哪几段提到了它,做交叉跳转
- 把
_convert_url_to_psycopg这个被复用 3 次的小函数提到services/utils.py,欠了三周的小账了
小结
SP12 的设计核心其实就两条:外部依赖可降级(vision key 不在也能跑 placeholder 全流程)、协议夹带(不改 SSE 主协议把结构化数据塞过去)。前者让我们不用等外部条件齐了再做,后者让前后端互相不阻塞演进。
到这周结束,大部分Agent 到位——路由、文献解析、检索、问答、追踪溯源、批判、综述、深度阅读、幻觉检查、质疑提问、代码执行、翻译、个性化总结、图表智能转化。每一个 Agent 都有自己的节点、自己的 prompt、自己在前端 timeline 里的 chip,现在整体能跑了,但是具体实现和效果还需要打磨串联。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)