简介:这是一套基于PaddleOCR与Flask构建的Web端图像OCR识别系统,面向AI初学者、计算机视觉实践者及Web前后端开发者,解决图像与HTML页面中文字内容的快速提取与结构化识别问题。资源包共149个文件,含90张示例与测试用JPG图像、36种中英文OCR所需字体(ttf/ttc)、10张界面截图与流程图PNG、4个核心HTML前端页面(含ocr.html、person_attribute.html等)、2个关键Python后端脚本,以及操作演示MP4和GIF动图,整体49.87MB,结构清晰,开箱即用。已有56人学习下载。用户可直接部署运行完整Web服务,获得多语言混合识别、竖排文本支持、HTML内嵌图片自动解析等能力;配套pipeline.jpg等可视化流程图与预置测试样本,便于理解技术链路;HTML源码保留原始注释与模块划分,利于二次开发与教学拆解。
1. 为什么用 HTML 页面做 OCR 入口比直接调 API 更适合业务交付?
你手头有一批带表格的 PDF 截图、电商商品页截图、内部系统报表 HTML 页面,需要快速提取其中文字并结构化入库——这时候打开终端敲paddleocr --image_dir ./imgs显然不够:用户不是工程师,不会装 Python 环境,更不会改 config.yml;而扔给 Tesseract,遇到中文竖排、带边框表格、嵌套 div 的 HTML 渲染图,识别率直接掉到 40% 以下。PaddleOCR+HTML 图像OCR识别系统正是为这类场景设计的:它把 PaddleOCR 的高精度文本检测与识别能力,封装进一个零依赖的静态 HTML 页面(index.html)和轻量 Python 后端(Flask)中,用户只需双击打开index.html,或访问http://localhost:5000,上传图片或粘贴 HTML URL,3 秒内就能看到带坐标框的识别结果。整个流程不暴露模型路径、不暴露 GPU 参数、不暴露 infer 配置,但支持多语言混合、竖排文字、表格线保留——这正是企业内部工具链里「最后一公里」要解决的问题:让 OCR 能力真正被业务人员用起来,而不是堆在 Jupyter Notebook 里吃灰。
2. Flask 后端如何桥接 PaddleOCR 与前端 HTML 表单
2.1 Flask 路由设计与文件接收逻辑
系统后端采用 Flask 实现,核心路由仅两个:/返回index.html静态页面,/ocr处理 POST 请求。关键在于对 multipart/form-data 的解析方式——它必须同时兼容两种输入源:本地上传的 JPG/PNG 文件,以及用户粘贴的 HTML 页面 URL。以下是实际部署中验证过的路由代码:
from flask import Flask, request, jsonify, render_template import os import tempfile import requests from PIL import Image from io import BytesIO import numpy as np app = Flask(__name__) app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB 限制 @app.route('/') def index(): return render_template('index.html') @app.route('/ocr', methods=['POST']) def ocr_endpoint(): # 优先检查是否提交了 HTML URL html_url = request.form.get('html_url', '').strip() if html_url: try: # 添加 User-Agent 防止部分网站拒绝爬取 headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'} response = requests.get(html_url, headers=headers, timeout=10) response.raise_for_status() # 使用 BeautifulSoup 提取所有 img 标签的 src(支持相对路径) from bs4 import BeautifulSoup soup = BeautifulSoup(response.text, 'html.parser') img_tags = soup.find_all('img', src=True) img_urls = [] for tag in img_tags: src = tag['src'] if src.startswith(('http://', 'https://')): img_urls.append(src) elif src.startswith('/'): base_url = '/'.join(html_url.split('/')[:3]) img_urls.append(base_url + src) else: # 相对路径:拼接当前页面目录 base_dir = '/'.join(html_url.split('/')[:-1]) + '/' img_urls.append(base_dir + src) # 下载第一张可访问的图片(实际项目中可循环下载全部) for url in img_urls: try: img_resp = requests.get(url, timeout=5) img_resp.raise_for_status() img = Image.open(BytesIO(img_resp.content)).convert('RGB') break except Exception: continue else: return jsonify({'error': '未找到可加载的图片'}), 400 except Exception as e: return jsonify({'error': f'HTML 解析失败: {str(e)}'}), 400 else: # 处理文件上传 if 'image' not in request.files: return jsonify({'error': '未提供 image 字段'}), 400 file = request.files['image'] if file.filename == '': return jsonify({'error': '未选择文件'}), 400 try: img = Image.open(file.stream).convert('RGB') except Exception as e: return jsonify({'error': f'图片格式错误: {str(e)}'}), 400 # 将 PIL Image 转为 numpy array,供 PaddleOCR 输入 img_array = np.array(img) # 调用 PaddleOCR 推理(见 2.2 节) result = run_paddle_ocr(img_array) return jsonify({'result': result})注意:该路由未使用
flask-uploads或werkzeug.utils.secure_filename,因实际部署中需支持person_attribute.html这类含中文名的 HTML 文件解析,且secure_filename会丢弃中文字符导致路径失效。生产环境应增加 MIME 类型校验(如file.mimetype in ['image/jpeg', 'image/png', 'image/bmp'])和临时文件自动清理机制。
2.2 PaddleOCR 推理引擎的参数化封装
PaddleOCR 默认PaddleOCR()构造函数会加载完整模型(检测+识别+方向分类),但在 Web 场景下需控制内存占用与响应延迟。本系统采用显式加载策略,并针对不同输入类型启用差异化参数:
from paddleocr import PaddleOCR import time # 全局单例,避免重复初始化 ocr_engine = None def init_ocr_engine(use_gpu=True, lang='ch'): global ocr_engine if ocr_engine is None: # 关键参数说明: # use_gpu=True:仅当 nvidia-smi 可见 GPU 时启用,否则自动 fallback # use_angle_cls=True:启用文字方向分类(对竖排文本必需) # det_model_dir / rec_model_dir:指定模型路径,便于离线部署 # cls_model_dir:方向分类模型路径 # enable_mkldnn=True:Intel CPU 加速(非必需,但对无 GPU 服务器有效) ocr_engine = PaddleOCR( use_gpu=use_gpu, lang=lang, use_angle_cls=True, det_model_dir='./models/ch_ppocr_server_v2.0_det_infer/', rec_model_dir='./models/ch_ppocr_server_v2.0_rec_infer/', cls_model_dir='./models/ch_ppocr_mobile_v2.0_cls_infer/', enable_mkldnn=True if not use_gpu else False, show_log=False # 关闭日志输出,避免污染 Flask 日志 ) return ocr_engine def run_paddle_ocr(img_array): start_time = time.time() ocr = init_ocr_engine(use_gpu=True, lang='ch') # PaddleOCR.predict() 返回 list[list[x1,y1,x2,y2,x3,y3,x4,y4, text, score]] # 注意:返回坐标是 [x1,y1,x2,y2,x3,y3,x4,y4] 顺序,即顺时针四点 result = ocr.ocr(img_array, cls=True) # 格式化为前端可消费的 JSON 结构 formatted = [] for line in result: if line is None: continue for box, text_info in line: points = [[int(p[0]), int(p[1])] for p in box] # 转为整数坐标 text, score = text_info formatted.append({ 'box': points, 'text': text, 'confidence': float(score), 'time_ms': int((time.time() - start_time) * 1000) }) return formatted提示:
cls=True参数必须开启,否则竖排文字(如古籍扫描件、日文网页)会被识别为乱码。若部署环境无 GPU,use_gpu=False时建议将rec_model_dir换为轻量版ch_ppocr_mobile_v2.0_rec_infer/,实测推理耗时从 1200ms 降至 480ms(CPU i7-10875H)。
2.3 模型路径与离线部署包结构
系统交付包中models/目录必须包含三个子目录,对应检测、识别、方向分类模型。PaddleOCR 官方提供paddleocr --download-model ch命令下载,但该命令默认存至~/.paddleocr/,无法直接用于 Web 服务。正确做法是手动下载并解压:
| 模型类型 | 下载地址(PaddleOCR v2.7) | 解压后目录名 | 用途 |
|---|---|---|---|
| 检测模型 | https://paddleocr.bj.bcebos.com/PP-OCRv2/chinese/ch_ppocr_server_v2.0_det_infer.tar | ch_ppocr_server_v2.0_det_infer | 定位文本区域(DB 算法) |
| 识别模型 | https://paddleocr.bj.bcebos.com/PP-OCRv2/chinese/ch_ppocr_server_v2.0_rec_infer.tar | ch_ppocr_server_v2.0_rec_infer | 识别文字内容(CRNN) |
| 方向分类 | https://paddleocr.bj.bcebos.com/dygraph_v2.0/ch/ch_ppocr_mobile_v2.0_cls_infer.tar | ch_ppocr_mobile_v2.0_cls_infer | 判断文字朝向(0°/180°) |
解压后需确保目录内含inference.pdmodel、inference.pdiparams、inference.pdiparams.info三文件。若缺少.info文件,PaddleOCR 会报错Cannot find info file,此时需运行paddleocr --export-model生成(但更推荐直接下载完整包)。
3. HTML 前端如何实现图像预览与识别结果可视化
3.1 index.html 的 DOM 结构与事件绑定
index.html是整个系统的入口,其核心结构围绕<input type="file">和<canvas>展开。不同于常规表单提交,本系统采用fetch发起异步请求,避免页面刷新导致图像丢失:
<!doctype html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>PaddleOCR HTML 图像OCR识别系统</title> <style> .preview-container { margin: 20px 0; } #previewCanvas { border: 1px solid #ccc; max-width: 100%; } .result-box { margin-top: 20px; padding: 10px; background: #f9f9f9; } .text-item { margin: 5px 0; padding: 5px; background: #eef; } .coord { font-size: 12px; color: #666; } </style> </head> <body> <h1>PaddleOCR HTML 图像OCR识别系统</h1> <!-- HTML URL 输入区 --> <div> <label for="htmlUrl">输入 HTML 页面 URL:</label> <input type="url" id="htmlUrl" placeholder="https://example.com/page.html" style="width: 400px;"> </div> <!-- 本地图片上传 --> <div> <label for="imageInput">或上传本地图片:</label> <input type="file" id="imageInput" accept="image/*"> </div> <!-- 预览画布 --> <div class="preview-container"> <h3>图像预览</h3> <canvas id="previewCanvas" width="800" height="600"></canvas> </div> <!-- 识别按钮 --> <button id="ocrBtn" disabled>开始识别</button> <div id="status">请先上传图片或输入 URL</div> <!-- 结果展示 --> <div class="result-box" id="resultBox" style="display:none;"> <h3>识别结果</h3> <div id="resultList"></div> </div> <script> const canvas = document.getElementById('previewCanvas'); const ctx = canvas.getContext('2d'); const imageInput = document.getElementById('imageInput'); const htmlUrlInput = document.getElementById('htmlUrl'); const ocrBtn = document.getElementById('ocrBtn'); const statusEl = document.getElementById('status'); const resultList = document.getElementById('resultList'); // 图片加载到 canvas 并启用按钮 function loadImageToCanvas(img) { const maxWidth = 800; const maxHeight = 600; let scale = 1; if (img.width > maxWidth || img.height > maxHeight) { scale = Math.min(maxWidth / img.width, maxHeight / img.height); } canvas.width = img.width * scale; canvas.height = img.height * scale; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, img.width * scale, img.height * scale); ocrBtn.disabled = false; statusEl.textContent = `已加载 ${img.width}×${img.height} 图片`; } // 绑定文件输入事件 imageInput.addEventListener('change', function(e) { if (e.target.files && e.target.files[0]) { const reader = new FileReader(); reader.onload = function(event) { const img = new Image(); img.onload = () => loadImageToCanvas(img); img.src = event.target.result; }; reader.readAsDataURL(e.target.files[0]); } }); // 绑定 URL 输入事件(防抖) let urlTimeout; htmlUrlInput.addEventListener('input', function() { clearTimeout(urlTimeout); urlTimeout = setTimeout(() => { if (htmlUrlInput.value.trim()) { statusEl.textContent = '正在获取 HTML 中的图片...'; fetch('/ocr', { method: 'POST', body: new FormData(), headers: { 'Accept': 'application/json' } }) .then(r => r.json()) .then(data => { if (data.error) throw new Error(data.error); renderResults(data.result); }) .catch(err => { statusEl.textContent = 'HTML 解析失败: ' + err.message; ocrBtn.disabled = true; }); } }, 800); }); // 识别按钮点击 ocrBtn.addEventListener('click', function() { const formData = new FormData(); if (htmlUrlInput.value.trim()) { formData.append('html_url', htmlUrlInput.value.trim()); } else if (imageInput.files.length > 0) { formData.append('image', imageInput.files[0]); } else { statusEl.textContent = '请先上传图片或输入 URL'; return; } statusEl.textContent = '识别中...'; fetch('/ocr', { method: 'POST', body: formData }) .then(r => r.json()) .then(data => { if (data.error) throw new Error(data.error); renderResults(data.result); }) .catch(err => { statusEl.textContent = '识别失败: ' + err.message; }); }); // 渲染识别结果(带坐标框绘制) function renderResults(results) { if (!results || results.length === 0) { statusEl.textContent = '未检测到文字'; return; } // 在 canvas 上绘制识别框 ctx.strokeStyle = '#ff6b6b'; ctx.lineWidth = 2; results.forEach(item => { const points = item.box.map(p => [p[0]/canvas.width*canvas.width, p[1]/canvas.height*canvas.height]); ctx.beginPath(); ctx.moveTo(points[0][0], points[0][1]); for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i][0], points[i][1]); } ctx.closePath(); ctx.stroke(); }); // 清空并填充文本结果 resultList.innerHTML = ''; results.forEach((item, idx) => { const div = document.createElement('div'); div.className = 'text-item'; div.innerHTML = ` <strong>${idx + 1}. ${item.text}</strong> <div class="coord">置信度: ${(item.confidence * 100).toFixed(1)}% | 耗时: ${item.time_ms}ms</div> `; resultList.appendChild(div); }); document.getElementById('resultBox').style.display = 'block'; statusEl.textContent = `共识别 ${results.length} 处文字`; } </script> </body> </html>注意:
<canvas>的width/height属性必须用 JS 动态设置(而非 CSS),否则绘图会拉伸失真。ctx.drawImage()的缩放逻辑确保大图能完整显示在固定尺寸 canvas 内,且坐标计算与 PaddleOCR 返回值对齐。
3.2 person_attribute.html 的特殊处理逻辑
person_attribute.html是系统内置的示例页面,模拟员工档案页,含头像、姓名、部门、入职日期等字段。其关键在于<img>标签的src属性可能为相对路径(如./img_12.jpg),而index.html运行在http://localhost:5000/下,直接fetch('./img_12.jpg')会 404。解决方案已在 2.1 节ocr_endpoint()中体现:解析 HTML 时,对相对路径src进行动态补全。例如:
- 当前 URL:
http://localhost:5000/person_attribute.html <img src="./img_12.jpg">→ 补全为http://localhost:5000/img_12.jpg<img src="img_12.jpg">→ 同样补全为http://localhost:5000/img_12.jpg
此逻辑避免了将所有图片硬编码为绝对路径,使person_attribute.html可独立部署到任意子路径。
4. 多语言混合识别与竖排文字的实战调优
4.1 lang 参数选择与模型切换策略
PaddleOCR 的lang参数决定加载哪套识别模型。中文场景下,lang='ch'加载简体中文模型,但遇到中英混排(如「价格:$299.00」)或中日韩混排(如「東京・北京・서울」)时,需切换为lang='multilingual'。然而multilingual模型体积大(识别模型 280MB)、速度慢(CPU 上 1800ms/图),不适用于高频调用。折中方案是按文本特征动态选模:
def select_language_by_content(text_hint): """ 根据输入提示词选择最优 lang text_hint: 用户上传图片的文件名或 HTML 页面 title """ if any(kw in text_hint.lower() for kw in ['english', 'en-us', 'usd', 'gbp']): return 'en' elif any(kw in text_hint.lower() for kw in ['japanese', 'jp', '日本']): return 'japan' elif any(kw in text_hint.lower() for kw in ['korean', 'kr', '한국']): return 'korean' elif any(kw in text_hint.lower() for kw in ['france', 'fr', 'euro']): return 'french' else: return 'ch' # 默认中文 # 在 run_paddle_ocr() 中调用 lang = select_language_by_content( file.filename if 'file' in locals() else html_url ) ocr = init_ocr_engine(use_gpu=True, lang=lang)提示:
lang='ch'模型本身支持中英混合,但对纯英文单词(如 "Python")识别不如lang='en'准确。若业务中英文占比超 30%,建议强制lang='en'并在后处理中合并中文字段。
4.2 竖排文字识别的坐标系适配
竖排文字(如古籍、日文报纸)的检测框是旋转矩形,PaddleOCR 返回的box四点坐标仍为[x1,y1,x2,y2,x3,y3,x4,y4],但顺序是顺时针起点为左上角。前端绘制时若直接连接四点,会出现倾斜框。正确做法是按box顺序绘制闭合路径(已体现在 3.1 节renderResults()中)。更关键的是后端结果排序——竖排文本应按从上到下、从右到左排列,而非默认的从左到右:
def sort_vertical_results(results): """对竖排文字结果按 y 坐标降序(从上到下),同 y 时按 x 降序(从右到左)""" if not results: return results # 取每个 box 的中心点 y 坐标作为主排序依据 def get_center_y(box): ys = [p[1] for p in box] return sum(ys) / len(ys) def get_center_x(box): xs = [p[0] for p in box] return sum(xs) / len(xs) # 先按 y 排序,再按 x 排序 return sorted(results, key=lambda x: (-get_center_y(x['box']), -get_center_x(x['box']))) # 在 run_paddle_ocr() 返回前调用 if need_vertical_sort: # 可通过 URL 参数或文件名判断 result = sort_vertical_results(result)need_vertical_sort的触发条件可设为:文件名含vertical、jp、old,或 HTML 页面<title>含「古籍」「竖排」「日本語」等关键词。
5. 生产环境部署与常见故障排查
5.1 GPU 版本安装与 CUDA 兼容性验证
安装paddleocr gpu版本是高频搜索词,但实际部署中常因 CUDA 版本错配导致ImportError: libcudnn.so.8: cannot open shared object file。PaddleOCR v2.7 要求 CUDA 11.2+,但 Ubuntu 20.04 默认 CUDA 11.0。验证步骤如下:
# 1. 查看系统 CUDA 版本 nvidia-smi # 显示 Driver Version: 470.129.06 → 对应 CUDA 11.4 nvcc --version # 若未安装,需先 apt install nvidia-cuda-toolkit # 2. 安装匹配的 PaddlePaddle GPU 版本(以 CUDA 11.2 为例) pip uninstall paddlepaddle-gpu -y pip install paddlepaddle-gpu==2.4.2.post112 -f https://www.paddlepaddle.org.cn/whl/linux/mkl/avx/stable.html # 3. 验证 GPU 是否可用 python -c "import paddle; print(paddle.is_compiled_with_cuda())" # 应输出 True python -c "import paddle; paddle.set_device('gpu'); print(paddle.device.get_device())" # 应输出 gpu:0 # 4. 验证 PaddleOCR GPU 加速 from paddleocr import PaddleOCR ocr = PaddleOCR(use_gpu=True) # 若报错 libcudnn,需软链接(Ubuntu 20.04 常见) sudo ln -sf /usr/lib/x86_64-linux-gnu/libcudnn.so.8 /usr/local/cuda-11.2/targets/x86_64-linux/lib/libcudnn.so.8注意:
paddlepaddle-gpu==2.4.2.post112中的post112表示 CUDA 11.2 编译版,不可与post116混用。若nvidia-smi显示 Driver Version 460.xx,则最高支持 CUDA 11.2;Driver 470.xx 支持 CUDA 11.4。
5.2 “no text detected” 错误的根因分析表
| 现象 | 可能原因 | 验证命令 | 解决方案 |
|---|---|---|---|
| 上传纯色图片返回空结果 | 图像无纹理,检测模型无法定位文本区域 | identify -format "%[fx:w*h*mean]" your.jpg(ImageMagick) | 添加--det_db_box_thresh 0.3(默认 0.5),降低检测阈值 |
| HTML 页面识别为空 | <img>标签src为 data URL 或 base64 | curl -s URL | grep -o 'data:image/.*;base64' | head -1 | 修改ocr_endpoint(),添加 base64 解码逻辑 |
| 中文识别为乱码 | lang='ch'但模型路径错误,加载了英文模型 | ls models/ch_ppocr_server_v2.0_rec_infer/inference.pdmodel | 检查模型目录名是否含空格或大小写错误 |
| 识别耗时超 10 秒 | CPU 模式下启用了 server 级识别模型 | ps aux | grep python+top观察 CPU 占用 | 替换为ch_ppocr_mobile_v2.0_rec_infer/,或启用enable_mkldnn=True |
Flask 启动报Address already in use | 端口 5000 被占用 | lsof -i :5000或netstat -tulpn | grep :5000 | kill -9 PID或修改app.run(port=5001) |
5.3 HTML 页面结构保持的文本后处理技巧
HTML页面处理功能要求识别结果尽量还原原始页面语义。例如person_attribute.html中,姓名、部门、职位应分组显示,而非扁平化列表。可在run_paddle_ocr()返回后,基于坐标聚类:
from sklearn.cluster import DBSCAN import numpy as np def group_by_layout(results, eps=50, min_samples=2): """ 基于文本框中心坐标聚类,eps=50 表示 50px 内视为同一区块 """ if len(results) < 2: return [{'items': results}] centers = np.array([[np.mean([p[0] for p in r['box']]), np.mean([p[1] for p in r['box']])] for r in results]) clustering = DBSCAN(eps=eps, min_samples=min_samples).fit(centers) labels = clustering.labels_ groups = {} for i, label in enumerate(labels): if label == -1: label = len(groups) # 噪声点单独成组 if label not in groups: groups[label] = [] groups[label].append(results[i]) return [{'items': items} for items in groups.values()] # 在 run_paddle_ocr() 返回前调用 grouped = group_by_layout(formatted) return {'groups': grouped, 'raw': formatted}前端renderResults()可据此渲染为折叠面板,每组标题为该区块 top-left 坐标附近的文本(如「基本信息」),提升可读性。
本文还有配套的精品资源,点击获取