用 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。

相关推荐
做cv的小昊1 小时前
【大模型算法自学笔记01】NLP基础知识(1.1 自注意力)
人工智能·笔记·算法·自然语言处理·大模型·llm
loulanyue_1 小时前
Context Is All You Need:读千问办公CEO陈宇森2026云栖演讲
人工智能·智能体·千问办公
零基础1231 小时前
深度强化学习驱动的 Agent 后训练:原理、实践与前沿路线
人工智能·python·机器学习
一缕82年的清风1 小时前
从 Cursor 杀回命令行:当 AI 接管终端,CLI 会取代 IDE 吗?
人工智能
夏文强1 小时前
国产开源反攻海外:GLM-5.3 进 Cursor,CursorBench 开放权重第一
人工智能·开源·大模型·glm·智谱
Yyyyyy~1 小时前
【机器学习】Numpy
人工智能·机器学习·numpy
高洁011 小时前
数字孪生驱动大模型工业知识库
人工智能·python·深度学习·机器学习·transformer
大模型真好玩1 小时前
DeepSeek Harness 桌面端来啦!更便捷更安全的选择
人工智能·agent·deepseek
远航计算机1 小时前
AI 爬虫分三种,你 robots.txt 里挡的是哪一种?
人工智能·爬虫·aigc