16.08-图片OCR

要点

  • OCR 在 AI 项目里的典型位置:扫描文档、截图、照片中的文字提取,作为 LLM 处理的前置步骤
  • Workers 环境的限制——Tesseract.js 的 Node.js 依赖和 sharp 的原生模块都跑不了,只能走 API 调用
  • 三条路径:Workers AI 视觉模型、多模态 LLM(GPT-4o / Claude)、外部 OCR API(Google Vision / Textract)
  • 图片预处理和质量优化对 OCR 精度影响大——分辨率、对比度、格式选择都需要处理

1. AI 项目里 OCR 的位置

AI 项目里,图片输入通常有两条路径:

路径一:图片 → OCR → 文本 → LLM 处理
路径二:图片 → 多模态 LLM → 直接输出结果

路径一适用于图片中主要是文字的场景:文档扫描件、应用截图、纸质表单。OCR 先把文字提取出来,再交给 LLM 做语义处理。好处是成本低、速度快,而且能保留文字的空间位置信息(表格结构、行列布局)。

路径二适用于需要理解图片内容的场景:流程图、架构图、带标注的 UI 截图。多模态 LLM 不只提取文字,还能理解视觉关系。代价是成本高、延迟大。

两条路径可以组合:OCR 提取文字,多模态 LLM 理解图片上下文,OCR 结果作为补充 token 喂给模型,提高文字部分的准确度。

2. Workers 环境的限制

先说清楚约束。Workers 运行在 V8 Isolate 上,不是完整的 Node.js 环境。这带来几个直接影响:

  1. Tesseract.js 不能用 — 虽然 Tesseract.js 有 WASM 构建版本,但它的默认依赖链需要 Node.js 的 fspath 等模块。在 Workers 里直接 import { createWorker } from 'tesseract.js' 会报错
  2. sharp 不能用 — sharp 依赖原生 C++ 绑定(libvips),Workers 不支持原生模块
  3. 内存有限 — Workers 的单次请求内存上限是 128 MB,处理高分辨率图片时需要注意

在 Workers 里做 OCR,能走的方向:

  • 用 Workers AI 提供的视觉模型(纯 API 调用,不需要本地依赖)
  • 调用外部 LLM API(GPT-4o、Claude),图片以 base64 或 URL 形式传过去
  • 调用外部 OCR API(Google Vision、AWS Textract)
  • 图片预处理放到客户端做,Workers 只负责接收和转发

3. 方案 A:Workers AI 视觉模型

Cloudflare Workers AI 提供了视觉模型,可以直接在 Worker 里跑 OCR,不需要调用外部 API。

// src/routes/ocr.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types'
 
export const ocrApp = new Hono<AppEnv>()
 
ocrApp.post('/ocr/workers-ai', async (c) => {
  const body = await c.req.parseBody()
  const file = body['image']
 
  if (!(file instanceof File)) {
    return c.json({ error: '请上传图片文件' }, 400)
  }
 
  const buffer = await file.arrayBuffer()
  const imageArray = [...new Uint8Array(buffer)]
 
  // Workers AI 视觉模型识别
  const ai = c.env.AI
  const response = await ai.run(
    '@cf/unum/uform-gen2-qwen-500m',
    {
      image: imageArray,
      prompt: '请提取图片中的所有文字内容,保持原有格式输出。',
    }
  )
 
  // response 包含模型生成的文本
  const text = (response as any).description ?? ''
 
  return c.json({
    text,
    source: 'workers-ai',
    charCount: text.length,
  })
})

Workers AI 的好处:数据不出 Cloudflare 网络,延迟低,不需要额外的 API key。

需要注意的是,Workers AI 的模型列表会更新,具体可用的 OCR 模型以 Cloudflare 文档 为准。调用时 image 参数是字节数组,prompt 字段用来指定识别要求。

4. 方案 B:多模态 LLM 直接识别

把图片发给 GPT-4o 或 Claude,让模型同时处理视觉和文字。精度最高,能理解复杂排版、手写体、中英文混合。

// src/lib/llm-ocr.ts
 
// 把图片 buffer 转成 base64 字符串(分段拼接,避免大文件时 call stack 溢出)
function bufferToBase64(buffer: ArrayBuffer): string {
  const bytes = new Uint8Array(buffer)
  let binary = ''
  for (let i = 0; i < bytes.length; i += 8192) {
    binary += String.fromCharCode(...bytes.subarray(i, i + 8192))
  }
  return btoa(binary)
}
 
export async function ocrWithGPT4o(
  imageBuffer: ArrayBuffer,
  apiKey: string,
): Promise<string> {
  const base64 = bufferToBase64(imageBuffer)
 
  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${apiKey}`,
    },
    body: JSON.stringify({
      model: 'gpt-4o',
      messages: [
        {
          role: 'system',
          content: '提取图片中的所有文字内容。保持原有格式和布局。表格用 Markdown 表格格式输出。',
        },
        {
          role: 'user',
          content: [
            {
              type: 'image_url',
              image_url: { url: `data:image/png;base64,${base64}` },
            },
          ],
        },
      ],
      max_tokens: 4096,
    }),
  })
 
  const data = await response.json()
  return data.choices?.[0]?.message?.content ?? ''
}

Claude 的调用方式类似,区别在端点和消息格式:用 https://api.anthropic.com/v1/messages,消息里 content 包含一个 type: 'image' 的 block,图片数据放在 source.data 里(base64),配合一个 type: 'text' 的 block 写 prompt。

多模态 LLM 做 OCR 的权衡:

  • 精度最高:复杂排版、手写体、中英文混合都能处理好
  • 成本最高:一张 1000×1000 的图约 1000 input tokens,按 GPT-4o 的价格算大约 $0.0025 / 张
  • 延迟最大:每次调用涉及完整的 LLM 推理,通常 2-5 秒
  • 适合需要语义理解的场景:不只是提取文字,还要理解文字含义和上下文

5. 方案 C:外部 OCR API

如果只需要提取文字(不需要语义理解),传统 OCR API 更合适——速度快、成本低。

Google Cloud Vision

// src/lib/google-vision-ocr.ts
export async function ocrWithGoogleVision(
  imageBuffer: ArrayBuffer,
  apiKey: string,
): Promise<{ text: string; confidence: number }> {
  const base64 = bufferToBase64(imageBuffer)
 
  const response = await fetch(
    `https://vision.googleapis.com/v1/images:annotate?key=${apiKey}`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        requests: [
          {
            image: { content: base64 },
            features: [
              { type: 'TEXT_DETECTION' },
              { type: 'DOCUMENT_TEXT_DETECTION' }, // 更精确的文档 OCR
            ],
            imageContext: {
              languageHints: ['zh', 'en'], // 指定语言,提高识别精度
            },
          },
        ],
      }),
    },
  )
 
  const data = await response.json()
  const annotation = data.responses?.[0]?.fullTextAnnotation
 
  return {
    text: annotation?.text ?? '',
    confidence: annotation?.pages?.[0]?.confidence ?? 0,
  }
}

AWS Textract

Textract 的 REST API 端点是 Textract.DetectDocumentText,传入 base64 编码的图片,返回按行组织的文本块。

不过 Textract 需要 AWS Signature V4 签名。生产环境建议直接用 @aws-sdk/client-textract

import { TextractClient, DetectDocumentTextCommand } from '@aws-sdk/client-textract'
 
const client = new TextractClient({ region: 'us-east-1' })
 
const command = new DetectDocumentTextCommand({
  Document: { Bytes: new Uint8Array(imageBuffer) },
})
 
const result = await client.send(command)
const text = result.Blocks?.filter(b => b.BlockType === 'LINE').map(b => b.Text).join('\n') ?? ''

SDK 会处理签名、重试和分页,省去自己实现 Signature V4 的麻烦。

6. 图片预处理

图片质量对 OCR 精度影响很大。Workers 里跑不了 sharp,预处理有几个替代方向。

base64 编码和格式转换

// src/lib/image-utils.ts
 
// 安全的 base64 编码——避免大文件时 call stack 溢出
export function bufferToBase64(buffer: ArrayBuffer): string {
  const bytes = new Uint8Array(buffer)
  let binary = ''
  const chunkSize = 8192
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize))
  }
  return btoa(binary)
}
 
// 检查图片格式并返回 MIME type
export function detectImageType(buffer: ArrayBuffer): string {
  const bytes = new Uint8Array(buffer)
  // PNG: 89 50 4E 47
  if (bytes[0] === 0x89 && bytes[1] === 0x50) return 'image/png'
  // JPEG: FF D8 FF
  if (bytes[0] === 0xFF && bytes[1] === 0xD8) return 'image/jpeg'
  // WebP: 52 49 46 46 ... 57 45 42 50
  if (bytes[0] === 0x52 && bytes[4] === 0x57) return 'image/webp'
  return 'application/octet-stream'
}

客户端预处理

Workers 里跑不了 sharp,图片预处理放到客户端做。用 Canvas API 处理后再上传:

// 客户端代码:上传前压缩 + 灰度化
function preprocessImage(file: File): Promise<Blob> {
  return new Promise((resolve) => {
    const img = new Image()
    img.onload = () => {
      const canvas = document.createElement('canvas')
 
      // 限制最大宽度 2000px
      const maxWidth = 2000
      const scale = Math.min(1, maxWidth / img.width)
      canvas.width = img.width * scale
      canvas.height = img.height * scale
 
      const ctx = canvas.getContext('2d')!
      ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
 
      // 灰度化 + 二值化:提升文字对比度
      const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
      for (let i = 0; i < imageData.data.length; i += 4) {
        const gray = imageData.data[i] * 0.299 + imageData.data[i + 1] * 0.587 + imageData.data[i + 2] * 0.114
        const v = gray > 128 ? 255 : 0
        imageData.data[i] = imageData.data[i + 1] = imageData.data[i + 2] = v
      }
      ctx.putImageData(imageData, 0, 0)
 
      // 转 JPEG,quality 0.85 是精度和体积的平衡点
      canvas.toBlob((blob) => resolve(blob!), 'image/jpeg', 0.85)
    }
    img.src = URL.createObjectURL(file)
  })
}

预处理后图片体积更小,OCR API 响应更快,精度也更高。

7. OCR 质量优化

影响 OCR 精度的几个因素:

  1. 分辨率 — 打印体英文 150 DPI 就够用,中文建议 300 DPI 以上。图片宽度低于 1000px 时,中文识别精度会下降
  2. 对比度 — 文字和背景对比度高,识别准确率高。扫描件背景发灰、发黄的,做一下灰度化 + 二值化
  3. 倾斜角度 — 拍照时倾斜超过 5° 就会影响识别率。传统 OCR 引擎通常内置倾斜校正,LLM 方案的容错性更好
  4. 语言指定 — 告诉 OCR 引擎识别哪种语言,能避免误识别。Google Vision 的 imageContext.languageHints 支持指定 ['zh', 'en'] 这样的语言组合
  5. 格式选择 — 线条文字、截图用 PNG(无损);照片用 JPEG(体积小);带透明通道的用 WebP。发给 OCR API 之前,确认对方支持的格式

8. 完整示例:上传图片 → OCR → 返回文本

把前面的内容串起来,一个完整的 OCR 路由。支持三种模式,通过 mode 参数切换:

// src/routes/ocr.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types'
import { bufferToBase64, detectImageType } from '../lib/image-utils'
 
export const ocrApp = new Hono<AppEnv>()
const MAX_IMAGE_SIZE = 10 * 1024 * 1024
 
ocrApp.post('/ocr', async (c) => {
  const body = await c.req.parseBody()
  const file = body['image']
 
  if (!(file instanceof File)) {
    return c.json({ error: '请上传图片文件' }, 400)
  }
  if (file.size > MAX_IMAGE_SIZE) {
    return c.json({ error: '图片大小不能超过 10MB' }, 400)
  }
 
  const buffer = await file.arrayBuffer()
  const mimeType = detectImageType(buffer)
  if (!mimeType.startsWith('image/')) {
    return c.json({ error: '不支持的图片格式' }, 400)
  }
 
  const mode = (body['mode'] as string) || 'llm'
  let text = ''
  let source = ''
 
  if (mode === 'workers-ai') {
    // 方案 A:Workers AI 视觉模型
    const ai = c.env.AI
    const result = await ai.run('@cf/unum/uform-gen2-qwen-500m', {
      image: [...new Uint8Array(buffer)],
      prompt: '请提取图片中的所有文字内容,保持原有格式输出。',
    })
    text = (result as any).description ?? ''
    source = 'workers-ai'
  } else if (mode === 'gpt4o') {
    // 方案 B:GPT-4o 多模态
    const base64 = bufferToBase64(buffer)
    const response = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${c.env.OPENAI_API_KEY}`,
      },
      body: JSON.stringify({
        model: 'gpt-4o',
        messages: [
          { role: 'system', content: '提取图片中的所有文字,保持原有格式。' },
          { role: 'user', content: [{ type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64}` } }] },
        ],
        max_tokens: 4096,
      }),
    })
    const data = await response.json()
    text = data.choices?.[0]?.message?.content ?? ''
    source = 'gpt-4o'
  } else {
    // 方案 C:Google Vision
    const base64 = bufferToBase64(buffer)
    const response = await fetch(
      `https://vision.googleapis.com/v1/images:annotate?key=${c.env.GOOGLE_VISION_KEY}`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          requests: [{
            image: { content: base64 },
            features: [{ type: 'DOCUMENT_TEXT_DETECTION' }],
            imageContext: { languageHints: ['zh', 'en'] },
          }],
        }),
      },
    )
    const data = await response.json()
    text = data.responses?.[0]?.fullTextAnnotation?.text ?? ''
    source = 'google-vision'
  }
 
  return c.json({ text, charCount: text.length, source })
})

挂载到入口文件:

// src/index.ts
import { ocrApp } from './routes/ocr'
app.route('/api', ocrApp)

客户端调用:

const formData = new FormData()
formData.append('image', file)
formData.append('mode', 'gpt4o')  // 可选:workers-ai / gpt4o / vision
 
const { text, charCount, source } = await fetch('/api/ocr', {
  method: 'POST',
  body: formData,
}).then(r => r.json())

9. 方案选择参考

场景推荐方案原因
文档扫描件(大批量)Google Vision / Textract成本低,速度快,精度高
截图文字提取GPT-4o / Claude能理解布局,处理复杂排版
表单字段提取Google Vision(表单专用)结构化提取,精度高
图片中的表格GPT-4o / Claude传统 OCR 难以理解表格结构
手写文字识别GPT-4o / Claude手写识别传统方案精度低
Workers 内部闭环Workers AI数据不出 Cloudflare,延迟最低
发票 / 收据专用票据 OCR结构化字段提取,精度最高

成本参考(以 1000 张图片计):Google Vision TEXT_DETECTION 约 $1.50,AWS Textract 约 $1.00-1.50,GPT-4o(按 ~1000 token / 张)约 $2.50,Workers AI 包含在配额内无额外按量费用。

总结

回顾这篇的要点:

  • Workers 环境的限制决定了 OCR 实现路径——Tesseract.js 和 sharp 都用不了,走 API 调用是唯一出路
  • Workers AI 提供视觉模型,能在 Worker 内完成 OCR,数据不出 Cloudflare 网络
  • 多模态 LLM(GPT-4o / Claude)精度最高,适合复杂场景——手写体、表格、混合语言
  • 传统 OCR API(Google Vision / Textract)成本低速度快,适合批量文档处理
  • 图片预处理放在客户端做——Workers 里没有原生图片处理能力
  • 分辨率、对比度、语言指定是影响 OCR 精度的三个关键因素
  • 方案选择按场景定:批量文档用传统 OCR,复杂场景用多模态 LLM,内部闭环用 Workers AI