用 Chrome 插件 + 局域网 Qwen2.5-VL 打造视频截屏 OCR 工具

在看网课、技术视频、会议录像的时候,经常想把画面里的代码、公式、PPT 文字直接抠出来。截图再丢给在线 OCR 既麻烦,又有隐私顾虑。本文用 Manifest V3 写一个 Chrome 插件:框选屏幕区域 → 保存截图 → 发给局域网里的 Qwen2.5-VL 7B → 识别结果自动进剪贴板 ,全程数据不出局域网。

启动qwen 2.5vl 7b大语言模型:

一、功能与整体思路

插件实现以下功能:

  1. 点击 OCR 按钮(或快捷键 Alt+Shift+O),在网页(包括全屏视频)上拖拽框选一块区域。
  2. 把选区裁剪成 PNG,保存到本地「下载/ocr」目录。
  3. 把图片转成 base64,发送给局域网中 Ollama 部署的 qwen2.5vl:7b,要求只识别文字。
  4. 识别结果自动写入剪贴板 ,同时显示在弹窗文本框和页面右下角浮动面板里,旁边都有 copy 按钮。

架构

Chrome 扩展的几个组件职责分工明确:

文件 运行环境 职责
popup.html / popup.js 弹窗页面 OCR 按钮、结果文本框、copy 按钮、设置
content.js 注入到网页 画遮罩、处理鼠标框选、显示结果面板
background.js Service Worker 截图裁剪、保存图片、调用大模型 API
offscreen.html / offscreen.js 离屏文档 写剪贴板(Service Worker 没有 DOM)
manifest.json --- 声明权限、入口、快捷键

数据流如下:

复制代码
popup 点击 OCR ──► background ──注入──► content.js 框选
                                              │ 发送选区坐标
                                              ▼
                  background:captureVisibleTab → 裁剪 → 下载 PNG
                                              │ base64
                                              ▼
                              Ollama /api/chat(qwen2.5vl:7b)
                                              │ 文字
                                              ▼
              offscreen 写剪贴板 + storage 存结果 + 通知页面和弹窗

目录结构:

复制代码
ocr-extension/
├── manifest.json
├── popup.html
├── popup.js
├── background.js
├── content.js
├── offscreen.html
├── offscreen.js
└── icons/
    ├── icon16.png
    ├── icon28.png
    └── icon128.png

C:\myApp\ChromePlugIn\ocr-extension\ocr-extension

二、环境准备:Ollama 服务端

在运行大模型的机器上(本文是 192.168.1.28)拉取模型:

bash 复制代码
ollama pull qwen2.5vl:7b
ollama list      # 确认模型名

要让局域网里的浏览器访问它,需要让 Ollama 监听所有网卡:

  • Windows:新建用户环境变量 OLLAMA_HOST=0.0.0.0:11434,并彻底退出 Ollama 后重启。
  • Linux 用 systemctl edit ollama 添加 Environment="OLLAMA_HOST=0.0.0.0:11434"。

另外要在防火墙里放行 TCP 11434(Windows 管理员 PowerShell):

powershell 复制代码
New-NetFirewallRule -DisplayName "Ollama 11434" -Direction Inbound -Protocol TCP -LocalPort 11434 -Action Allow -Profile Private,Domain

在另一台电脑上用 curl 验证服务可用:

bash 复制代码
curl http://192.168.1.28:11434/api/chat -d '{"model":"qwen2.5vl:7b","stream":false,"messages":[{"role":"user","content":"你好"}]}'

能返回正常 JSON,说明服务端就绪。

三、核心源码详解

1. manifest.json:权限是关键

json 复制代码
{
  "manifest_version": 3,
  "name": "视频截屏 OCR (Qwen2.5-VL)",
  "version": "1.0.2",
  "permissions": ["activeTab", "scripting", "storage", "downloads",
                  "offscreen", "clipboardWrite", "declarativeNetRequest"],
  "host_permissions": ["http://192.168.1.28:11434/*"],
  "background": { "service_worker": "background.js" },
  "action": {
    "default_popup": "popup.html",
    "default_icon": { "16": "icons/icon16.png", "28": "icons/icon28.png", "128": "icons/icon128.png" }
  },
  "commands": {
    "start-ocr": {
      "suggested_key": { "default": "Alt+Shift+O" },
      "description": "开始框选 OCR"
    }
  }
}

每个权限都有用途:

  • activeTab:用户点击插件或按快捷键后,获得对当前标签页的临时访问权,captureVisibleTab 靠它截图,不需要申请 <all_urls> 这种过大的权限。
  • scripting:动态注入 content.js。
  • downloads:保存截图。
  • offscreen + clipboardWrite:后面讲剪贴板时会解释。
  • host_permissions:授权访问 Ollama 地址。有了它,Service Worker 里的 fetch 可以绕过浏览器的 CORS 检查。
  • declarativeNetRequest:用于修改请求头,解决 Ollama 的 403 问题(见后文)。

2. content.js:框选遮罩

这是交互的核心,思路是在页面最上层盖一个全屏半透明 div,监听鼠标事件画选框。

js 复制代码
const host = () => document.fullscreenElement || document.documentElement;

这一行很重要。视频进入全屏后,只有全屏元素及其子树才可见。如果还把遮罩挂在 document.body 上,全屏视频时就看不到遮罩。所以挂载点优先取 document.fullscreenElement,这样全屏播放时也能框选。

创建遮罩和选框:

js 复制代码
const overlay = document.createElement('div');
overlay.style.cssText =
  'position:fixed;inset:0;z-index:2147483647;cursor:crosshair;background:rgba(0,0,0,.25);';
const box = document.createElement('div');
box.style.cssText = 'position:fixed;border:2px solid #ff3b30;background:rgba(255,59,48,.12);display:none;';
overlay.appendChild(box);
host().appendChild(overlay);

z-index 用 32 位整数的最大值 2147483647,保证盖住页面上一切元素。

三个鼠标事件的逻辑:

js 复制代码
overlay.addEventListener('mousedown', (e) => {
  drag = true; sx = e.clientX; sy = e.clientY;
  Object.assign(box.style, { display: 'block', left: sx + 'px', top: sy + 'px', width: '0', height: '0' });
  e.preventDefault();
});
overlay.addEventListener('mousemove', (e) => {
  if (!drag) return;
  Object.assign(box.style, {
    left: Math.min(sx, e.clientX) + 'px', top: Math.min(sy, e.clientY) + 'px',
    width: Math.abs(e.clientX - sx) + 'px', height: Math.abs(e.clientY - sy) + 'px'
  });
});

这里用 Math.min 和 Math.abs 处理,是为了支持从任意方向拖拽(比如从右下往左上拖),否则宽高会变成负数。

松开鼠标后最关键的一段:

js 复制代码
overlay.addEventListener('mouseup', async (e) => {
  if (!drag) return;
  drag = false;
  const r = {
    x: Math.min(sx, e.clientX), y: Math.min(sy, e.clientY),
    w: Math.abs(e.clientX - sx), h: Math.abs(e.clientY - sy),
    viewportW: window.innerWidth
  };
  cleanup();                        // 先移除遮罩
  if (r.w < 5 || r.h < 5) return;   // 太小视为误触
  // 等遮罩真正消失后再截图,避免红框被截进去
  await new Promise((ok) => requestAnimationFrame(() =>
    requestAnimationFrame(() => setTimeout(ok, 80))));
  chrome.runtime.sendMessage({ type: 'REGION_SELECTED', ...r });
});

这里有个容易踩的坑:移除遮罩之后,必须等浏览器真正重绘完,才能让后台去截图 。否则截到的图片里会带着红色选框和半透明黑色。所以用两层 requestAnimationFrame 再加 80ms 延迟,确保画面已经刷新。

同时把 window.innerWidth(视口的 CSS 宽度)一并发给后台,下一步换算像素比例时要用。

重复点击 OCR 时,脚本会被再次注入,开头用一个全局标记避免重复挂监听:

js 复制代码
if (window.__ocrStart) { window.__ocrStart(); return; }

3. background.js:截图与裁剪

chrome.tabs.captureVisibleTab 返回的是整个可见区域的截图(data URL),需要按选区裁剪:

js 复制代码
async function captureAndCrop(tab, r) {
  const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' });
  const bin = atob(dataUrl.split(',')[1]);
  const bytes = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  const bmp = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));

  const k = bmp.width / r.viewportW;             // 实际像素 / CSS 像素
  const sx = Math.round(r.x * k), sy = Math.round(r.y * k);
  const sw = Math.max(1, Math.round(r.w * k)), sh = Math.max(1, Math.round(r.h * k));

  const canvas = new OffscreenCanvas(sw, sh);
  canvas.getContext('2d').drawImage(bmp, sx, sy, sw, sh, 0, 0, sw, sh);
  return await canvas.convertToBlob({ type: 'image/png' });
}

要点解释:

  • 缩放比例 k :在高分屏或开了系统缩放(比如 Windows 125%、150%)时,CSS 像素和截图的实际像素不是 1:1。用 截图宽度 / 视口 CSS 宽度 算出比例,比直接读 devicePixelRatio 更可靠,因为浏览器页面缩放也会被一并算进去。
  • Service Worker 里没有 document ,不能用普通 <canvas>,要用 OffscreenCanvas,createImageBitmap 也是 Worker 里可用的。
  • 手动解码 base64 :我最初用 fetch(dataUrl) 把 data URL 转成 Blob,调试时发现这种写法一旦出错,提示也只是笼统的 "Failed to fetch",很难定位。改成 atob 手动解码后,既稳定又方便区分错误来源。

4. 调用 Qwen2.5-VL:提示词与请求体

js 复制代码
const PROMPT =
  'Extract all the text visible in this image exactly as it appears, keeping line breaks. ' +
  'Output ONLY the recognized text, without any explanation, translation or markdown. ' +
  'If there is no text, output nothing.';

OCR 场景的提示词要点:只输出文字、不要解释、不要翻译、不要加 Markdown,否则视觉语言模型很喜欢"这张图片里写着......"这类废话,影响后续直接粘贴。

请求体使用 Ollama 的 /api/chat 接口,图片放在 images 字段,值是不带 data:image/png;base64, 前缀的纯 base64:

js 复制代码
body: JSON.stringify({
  model: cfg.model,
  stream: false,                 // 一次性返回完整结果,便于处理
  options: { temperature: 0 },   // OCR 需要确定性,温度设为 0
  messages: [{ role: 'user', content: PROMPT, images: [b64] }]
})

返回结果在 data.message.content。

5. 踩坑重点:Ollama 的 403 与 Origin

首次联调时,弹窗里报错 「API 返回 403」 。原因是:浏览器扩展发出的请求会带 Origin: chrome-extension://<扩展ID>,而 Ollama 默认只允许本机来源,对其他来源直接返回 403(且响应体为空)。

有两种解决方案:

方案 A(服务端) :设置环境变量 OLLAMA_ORIGINS=chrome-extension://*,重启 Ollama。

方案 B(客户端) :用 declarativeNetRequest 在发请求时把 Origin 头删掉:

js 复制代码
async function applyOriginRule() {
  const { apiUrl } = await getSettings();
  const base = new URL(apiUrl).origin + '/';
  await chrome.declarativeNetRequest.updateDynamicRules({
    removeRuleIds: [1],
    addRules: [{
      id: 1,
      priority: 1,
      action: { type: 'modifyHeaders',
                requestHeaders: [{ header: 'Origin', operation: 'remove' }] },
      condition: { urlFilter: '|' + base, resourceTypes: ['xmlhttprequest', 'other'] }
    }]
  });
}

我最终把两者结合:默认不改请求头,遇到 403 或网络层失败时,才自动启用这条规则重试 ,对应 callOllama:

js 复制代码
async function callOllama(cfg, b64) {
  const url = cfg.apiUrl.replace(/\/$/, '') + '/api/chat';
  const init = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: /* ... */ };

  await clearOriginRule();                  // 先不改任何请求头
  let resp;
  try {
    resp = await fetch(url, init);
  } catch (e) {                             // 网络层失败:换成"去掉 Origin"再试一次
    await applyOriginRule();
    resp = await fetch(url, init);
  }
  if (resp.status === 403) {                // 来源检查被拒:去掉 Origin 后重试
    await applyOriginRule();
    resp = await fetch(url, init);
  }
  return resp;
}

这样无论服务端有没有配置 OLLAMA_ORIGINS,插件都能工作。

6. 写剪贴板:为什么需要 offscreen 文档

需求要求"识别结果自动进入剪贴板"。但是:

  • Service Worker 没有 DOM,不能调用 navigator.clipboard;
  • 页面里的 navigator.clipboard.writeText 要求页面处于聚焦状态,并且通常要有用户手势,自动写入很不稳定。

Manifest V3 给出的标准方案是 Offscreen Document :创建一个不可见的扩展页面,在里面借助 execCommand('copy') 写入剪贴板。

background.js 一侧:

js 复制代码
async function copyToClipboard(text) {
  try {
    await chrome.offscreen.createDocument({
      url: 'offscreen.html',
      reasons: ['CLIPBOARD'],
      justification: '把 OCR 结果写入剪贴板'
    });
  } catch (e) { /* 已存在则忽略 */ }
  await chrome.runtime.sendMessage({ target: 'offscreen', type: 'COPY', text });
}

offscreen.js 一侧:

js 复制代码
chrome.runtime.onMessage.addListener((msg) => {
  if (msg.target !== 'offscreen' || msg.type !== 'COPY') return;
  const t = document.getElementById('t');   // offscreen.html 里放了一个 textarea
  t.value = msg.text;
  t.select();
  document.execCommand('copy');
});

注意消息里用了 target: 'offscreen' 做路由:runtime.sendMessage 会广播给扩展内所有页面,背景脚本自己的监听器遇到 target === 'offscreen' 的消息要直接忽略,反之亦然。

7. 结果展示:storage 作为"总线"

弹窗(popup)一旦失去焦点就会关闭,而用户框选时必然要点页面,所以弹窗不可能一直开着等结果 。我的做法是把状态和结果统一存到 chrome.storage.local:

js 复制代码
async function notify(tabId, type, text) {
  await chrome.storage.local.set(
    type === 'OCR_RESULT' ? { lastResult: text, status: '完成' } : { status: text });
  try { await chrome.tabs.sendMessage(tabId, { type, text }); } catch (e) {}
}
  • 页面里的浮动面板通过 tabs.sendMessage 实时更新;
  • 弹窗下次打开时,读取 lastResult 显示出来,并且监听 storage.onChanged 动态刷新:
js 复制代码
chrome.storage.onChanged.addListener((c) => {
  if (c.lastResult) $('result').value = c.lastResult.newValue || '';
  if (c.status) $('status').textContent = c.status.newValue || '';
});

识别完成后,还会尝试 chrome.action.openPopup() 自动弹出弹窗(新版 Chrome 支持,失败则忽略)。

8. popup.js:启动与 copy 按钮

js 复制代码
$('ocr').onclick = async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  await chrome.storage.local.set({ status: '请在页面上拖拽框选区域(Esc 取消)' });
  chrome.runtime.sendMessage({ type: 'START_OCR', tabId: tab.id });
  window.close();   // 关闭弹窗,让用户在页面上框选
};

$('copy').onclick = async () => {
  const v = $('result').value;
  try { await navigator.clipboard.writeText(v); }
  catch (e) { $('result').select(); document.execCommand('copy'); }
  $('copy').textContent = 'copied ✓';
  setTimeout(() => ($('copy').textContent = 'copy'), 1200);
};

copy 按钮是用户手动点击,属于有用户手势的场景,navigator.clipboard.writeText 可以直接用,失败时再退回到 execCommand。

四、安装方法

  1. 把源码放进一个文件夹,比如 ocr-extension,结构与上文一致(icons 里放 16、28、128 三个尺寸的 PNG 图标)。
  2. 在 Chrome 地址栏输入 chrome://extensions。
  3. 右上角打开 开发者模式。
  4. 点击左上角 加载已解压的扩展程序 ,选择 ocr-extension 文件夹。
  5. 在工具栏的拼图图标里,把插件 固定 到工具栏,方便点击。
  6. 修改了代码后,在扩展管理页点击插件卡片上的 刷新 按钮,并刷新正在测试的网页(旧页面里还残留着上一版注入的脚本)。

五、使用方法

  1. 打开视频网页,把视频暂停在想识别的那一帧(视频全屏也可以)。
  2. 点击工具栏里的插件图标,再点蓝色的 OCR:框选屏幕区域 按钮;也可以直接按快捷键 Alt+Shift+O。
  3. 页面变暗后,按住鼠标左键拖出一个框,把要识别的文字框住。按 Esc 可取消。
  4. 松开鼠标,右下角出现「正在截图... / 识别中...」提示。
  5. 识别完成后,文字已经在剪贴板里 ,可以直接粘贴。结果同时显示在页面右下角面板和弹窗文本框里,点旁边的 copy 可以再次复制。
  6. 截图会同时保存到浏览器下载目录的 ocr 子文件夹。如果不需要,在弹窗「设置」里取消勾选即可。

在弹窗的「设置」里还可以修改:

  • API 地址 (默认 http://192.168.1.28:11434);
  • 模型名称 (默认 qwen2.5vl:7b)。

注意:如果把 API 地址改成其他主机,需要同步修改 manifest.json 里的 host_permissions,然后刷新插件。

六、常见问题

现象 原因与处理
弹窗提示 API 返回 403 Ollama 拒绝了扩展来源。服务端设置 OLLAMA_ORIGINS=chrome-extension://* 并重启;或使用本文的自动去 Origin 逻辑
Failed to fetch 网络不通:检查 Ollama 是否在运行、OLLAMA_HOST 是否为 0.0.0.0、防火墙是否放行 11434 端口
model not found 模型名不对,用 ollama list 查准确名称
识别很慢 首次调用需要把 7B 视觉模型加载进显存;之后会快很多
结果为空或乱码 框选区域太小或文字太小,放大视频或扩大选框
受 DRM 保护的视频截出黑屏 这是浏览器的版权保护限制,无法绕过
chrome:// 页面和应用商店页面不能用 Chrome 不允许在这些页面注入脚本

七、总结与扩展

这个小工具把几个 Manifest V3 里比较有代表性的知识点串在了一起:

  • content script 负责页面交互,service worker 负责耗时的后台任务,两者通过消息通信;
  • captureVisibleTab + OffscreenCanvas 完成截图和裁剪,并且处理好高分屏的缩放比例;
  • Service Worker 访问局域网 API 时用 host_permissions 绕过 CORS,并用 declarativeNetRequest 修改请求头;
  • Service Worker 无法直接操作剪贴板,借助 offscreen 文档解决;
  • 弹窗生命周期很短,用 chrome.storage 做状态中转。

后续还可以继续扩展:

  • 固定区域 + 定时识别:比如每隔 3 秒自动识别视频字幕区域,实现实时字幕提取;
  • 识别后自动翻译:把提示词换成"识别并翻译成中文";
  • 识别结果带格式:让模型输出 Markdown 表格或 LaTeX 公式,用来提取课件中的表格和公式;
  • 历史记录 :把每次识别的图片和文字存进 storage,做成可检索的列表。

整个方案的数据只在局域网内流转,不依赖任何第三方云服务,对隐私敏感的场景(内部培训视频、会议录像)尤其合适。希望这篇文章对你有帮助,如果你有更好的想法,欢迎在评论区交流。

需要的话,我可以把这篇博客保存成一个 .md 文件,方便你直接上传到 CSDN。

相关推荐
在所不辞兄18 小时前
【零基础学智能仿真-63】ABACUS作业工具与MCP接口
人工智能·深度学习·神经网络·机器学习·智能仿真
楚山之石18 小时前
【Medical Image Analysis】深度学习医学图像合成综述:伪CT、合成MRI与合成PET的架构、性能与临床边界
图像处理·人工智能·学习·mr·mri
blackappleD18 小时前
百度 Unlimited OCR 2 拆解:SR-SWA 如何让百页文档的 KV 缓存不再涨
人工智能·llm
数字新视界18 小时前
国产动环数据库在智能机房环境监控中的应用与优势分析
大数据·人工智能·数据中心·机房管理·模块化机房
holly就是我18 小时前
AI 编程助手为什么跑偏?用开源 ccglass 看清 Prompt、工具调用和 Token 消耗
人工智能·后端
雨中飘荡的记忆18 小时前
额度又见底了?这个开源项目,给 AI 编程助手接上 61 家"加油站"
人工智能
天远Date Lab18 小时前
零信任架构实战:基于天远企业年报信息核验构建自动化商业保理财务与社保穿透评估网关
人工智能·架构·自动化
EnCi Zheng19 小时前
后端怎么把AI接口稳定安全低成本接进业务:超时重试、密钥隔离与成本熔断清单
人工智能·后端·安全·架构·api
loulanyue_19 小时前
给概率大模型套上确定性缰绳:GraphRAG、神经符号与知识超图重构可信AI底座——读陈华钧杨博等CNCC2026第十届知识图谱论坛实录
人工智能·rag
古法老登瞎折腾19 小时前
大数据智能运维实战(九):让 AI 查日志——找到异常,也保留上下文
java·大数据·运维·人工智能·spring ai·mcp