PP-OCRv6 本地 GPU 测试项目搭建实录

前言

PaddleOCR 3.x 发布了 PP-OCRv6 系列模型,在精度和速度上都有明显提升。

官方文档见:

https://www.paddleocr.ai/latest/version3.x/pipeline_usage/OCR.html

本文记录从零搭建一个本地测试项目的完整过程,目标如下:

  • 使用 uv 管理 Python 依赖
  • 推理引擎使用 paddle_static(常规模式,对应 Paddle Inference 静态图)
  • 使用 GPU 版本 PaddlePaddle 进行推理
  • 默认模型 PP-OCRv6_medium
  • 提供 OCR 结果 JSON 可视化页面,在原图上叠加识别框和文字列表

测试环境:Windows 11、NVIDIA GTX 1060 6GB、Python 3.12、CUDA 11.8。

技术选型

项目 选择 说明
包管理 uv 快速、支持自定义 index 源
OCR paddleocr ≥ 3.0.3 内置 PP-OCRv6 产线
推理框架 paddlepaddle-gpu 3.2.2 cu118 源安装
推理引擎 paddle_static PaddleOCR 默认常规模式
可视化 纯 HTML + SVG 无需前端构建工具

项目结构

1
2
3
4
5
6
7
8
9
10
z-orc-ppocrv6/
├── pyproject.toml # uv 依赖与脚本入口
├── .python-version # Python 3.12
├── .gitignore
├── index.html # OCR 结果可视化页面
├── scripts/
│ ├── verify_env.py # 环境验证
│ └── run_ocr.py # OCR 推理
├── samples/ # 示例图片(首次运行自动下载)
└── output/ # 推理结果 JSON / 可视化图

环境准备

前置条件

  • Windows 64 位
  • NVIDIA GPU(计算能力 ≥ 6.0)
  • uv 已安装
  • 显卡驱动已安装(本机 Driver 582 + CUDA 11.8 运行时)

安装依赖

paddlepaddle-gpu 需要从飞桨官方 cu118 源安装,Windows 下还需额外安装 NVIDIA 运行时 DLL。

1
2
cd z-orc-ppocrv6
uv sync --group gpu

验证环境

1
uv run verify-env

期望输出类似:

1
2
3
4
5
PaddleOCR 版本: 3.7.0
Paddle 版本: 3.2.2
GPU 编译支持: True
GPU 数量: 1
当前设备: gpu:0

依赖配置

pyproject.toml 完整内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
[project]
name = "z-orc-ppocrv6"
version = "0.1.0"
description = "PaddleOCR PP-OCRv6 本地 GPU 测试项目(paddle_static 推理引擎)"
readme = "README.md"
requires-python = ">=3.10,<3.13"
dependencies = [
"paddleocr>=3.0.3",
"paddlepaddle-gpu==3.2.2",
]

[dependency-groups]
# Windows 下 paddlepaddle-gpu 不会自动拉取 NVIDIA 运行时,需额外安装
gpu = [
"nvidia-cuda-runtime-cu11==11.8.89",
"nvidia-cudnn-cu11==8.9.4.19",
"nvidia-cublas-cu11==11.11.3.6",
"nvidia-cufft-cu11==10.9.0.58",
"nvidia-curand-cu11==10.3.0.86",
"nvidia-cusolver-cu11==11.4.1.48",
"nvidia-cusparse-cu11==11.7.5.86",
]

[project.scripts]
verify-env = "scripts.verify_env:main"
run-ocr = "scripts.run_ocr:main"

[tool.uv]
environments = ["sys_platform == 'win32'"]

[[tool.uv.index]]
name = "paddle-cu118"
url = "https://www.paddlepaddle.org.cn/packages/stable/cu118/"
explicit = true

[tool.uv.sources]
paddlepaddle-gpu = { index = "paddle-cu118" }

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["scripts"]

.python-version

1
3.12

.gitignore

1
2
3
4
5
6
7
8
9
10
11
.venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.mypy_cache/
output/
samples/
.paddlex/
*.log
uv.lock

环境验证脚本

scripts/verify_env.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
"""验证 PaddleOCR + paddle_static (PaddlePaddle GPU) 环境。"""

from __future__ import annotations


def main() -> None:
import paddle
import paddleocr

print(f"PaddleOCR 版本: {paddleocr.__version__}")
print(f"Paddle 版本: {paddle.__version__}")
print(f"GPU 编译支持: {paddle.is_compiled_with_cuda()}")

if paddle.is_compiled_with_cuda():
print(f"GPU 数量: {paddle.device.cuda.device_count()}")
if paddle.device.cuda.device_count() > 0:
paddle.device.set_device("gpu:0")
print("当前设备: gpu:0")
else:
print("警告: 当前 Paddle 未启用 CUDA,请检查 paddlepaddle-gpu 安装。")


if __name__ == "__main__":
main()

OCR 推理脚本

scripts/run_ocr.py 使用 PP-OCRv6 + paddle_static 引擎,关闭文档预处理模块以加快推理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
"""使用 PP-OCRv6 + paddle_static 引擎在 GPU 上运行 OCR 推理。"""

from __future__ import annotations

import argparse
import os
from pathlib import Path
from urllib.parse import quote

DEFAULT_IMAGE_URL = (
"https://paddle-model-ecology.bj.bcebos.com/paddlex/imgs/demo_image/"
"general_ocr_002.png"
)


def _resolve_input(image: str | None) -> str:
if image:
return image

samples_dir = Path(__file__).resolve().parent.parent / "samples"
samples_dir.mkdir(exist_ok=True)
local_path = samples_dir / "general_ocr_002.png"

if local_path.exists():
return str(local_path)

try:
import urllib.request

print(f"下载示例图片: {DEFAULT_IMAGE_URL}")
urllib.request.urlretrieve(DEFAULT_IMAGE_URL, local_path)
return str(local_path)
except Exception as exc:
raise SystemExit(
"未指定图片且自动下载失败,请手动下载示例图或使用 --image 指定路径。\n"
f"示例 URL: {DEFAULT_IMAGE_URL}\n"
f"错误: {exc}"
) from exc


def main() -> None:
parser = argparse.ArgumentParser(description="PP-OCRv6 GPU OCR 测试")
parser.add_argument(
"--image",
"-i",
help="待识别图片路径或 URL(默认自动下载官方示例图)",
)
parser.add_argument(
"--output",
"-o",
default="output",
help="结果输出目录(默认: output)",
)
parser.add_argument(
"--device",
default="gpu:0",
help="推理设备(默认: gpu:0)",
)
parser.add_argument(
"--html",
action="store_true",
help="推理完成后打印可视化页面地址",
)
args = parser.parse_args()

input_path = _resolve_input(args.image)
output_dir = Path(args.output)
output_dir.mkdir(parents=True, exist_ok=True)

from paddleocr import PaddleOCR

# 默认 engine=None 即 paddle_static;显式指定以便文档对齐
ocr = PaddleOCR(
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_textline_orientation=False,
device=args.device,
engine="paddle_static",
)

print(f"输入: {input_path}")
print(f"设备: {args.device}")
print(f"引擎: paddle_static")
print("开始推理...")

result = ocr.predict(input_path)
for res in result:
res.print()
res.save_to_img(str(output_dir))
res.save_to_json(str(output_dir))

if args.html:
project_root = Path(__file__).resolve().parent.parent
for json_file in sorted(output_dir.glob("*_res.json")):
json_rel = os.path.relpath(json_file.resolve(), project_root).replace("\\", "/")
url = f"index.html?json={quote(json_rel)}"
print(f"可视化页面: http://127.0.0.1:8080/{url}")
print("提示: 先运行 `uv run python -m http.server 8080`,再在浏览器打开上述地址")

print(f"完成,结果已保存至: {output_dir.resolve()}")


if __name__ == "__main__":
main()

运行推理

1
2
3
4
5
6
7
8
# 自动下载官方示例图并推理
uv run run-ocr

# 指定本地图片
uv run run-ocr --image path/to/image.png

# 推理后打印可视化地址
uv run run-ocr --html

命令行等价方式

1
2
3
4
5
6
uv run paddleocr ocr -i samples/general_ocr_002.png `
--use_doc_orientation_classify False `
--use_doc_unwarping False `
--use_textline_orientation False `
--save_path ./output `
--device gpu:0

输出文件

推理完成后 output/ 目录包含:

  • general_ocr_002_res.json:识别结果(文本、坐标、置信度)
  • general_ocr_002_ocr_res_img.png:带框可视化图

JSON 核心字段:

1
2
3
4
5
6
7
{
"input_path": "D:\\Project\\...\\samples\\general_ocr_002.png",
"rec_texts": ["登机牌", "BOARDING", "..."],
"rec_scores": [0.99, 0.99, "..."],
"dt_polys": [[[1, 7], [285, 5], ...]],
"rec_boxes": [[1, 5, 285, 32], ...]
}

字段说明

字段 类型 说明
input_path string 原始输入图片路径,可视化页面据此自动加载图片
page_index number | null 页码索引,单张图片时为 null
model_settings object 产线模块开关,如是否启用文档预处理、文本行方向分类
dt_polys array 文本检测框,每个元素为四点多边形 [[x,y], ...],与 rec_texts 一一对应
text_det_params object 检测阶段参数,含 threshbox_threshunclip_ratio
text_type string 文本类型,通用场景为 general
textline_orientation_angles array 文本行方向角度,未启用方向分类时多为 -1
text_rec_score_thresh number 识别结果过滤阈值,低于该分数的文本会被丢弃
return_word_box boolean 是否返回单字级别坐标
rec_texts array 识别出的文本内容列表
rec_scores array 每条文本的置信度,范围 0~1,与 rec_texts 一一对应
rec_polys array 识别框多边形坐标,形状与 dt_polys 类似
rec_boxes array 识别框外接矩形 [x1, y1, x2, y2],便于快速绘制矩形

可视化页面

index.html 放在项目根目录,功能:

  • 只需加载 JSON,图片路径从 input_path 自动解析
  • 左侧原图 + SVG 多边形识别框
  • 右侧文字列表,显示置信度
  • 点击框或列表项联动高亮
  • 支持搜索过滤
  • 页面最外层无滚动条,图片区和列表各自内部滚动

启动方式

1
uv run python -m http.server 8080

浏览器访问:

1
http://127.0.0.1:8080/index.html?json=output/general_ocr_002_res.json

或手动填写 output/general_ocr_002_res.json 后点击「渲染」。

必须通过 HTTP 服务打开,不要直接双击 index.htmlfile:// 协议下 fetch 无法加载图片)。

核心逻辑

路径解析

JSON 中 input_path 通常是 Windows 绝对路径,页面会提取项目相对路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
const PROJECT_DIRS = ["samples", "output", "images", "data", "input"];

function resolveImagePath(inputPath, jsonPath) {
const norm = inputPath.replace(/\\/g, "/");
// 绝对路径:从 samples/、output/ 等目录截取相对路径
if (/^[a-zA-Z]:\//.test(norm) || norm.startsWith("/")) {
for (const dir of PROJECT_DIRS) {
const idx = norm.toLowerCase().indexOf(`${dir}/`);
if (idx >= 0) return norm.slice(idx);
}
}
// ...
}

站点根目录

index.html 位于项目根目录,fetch 时以当前页面目录为根:

1
2
3
4
5
6
7
8
9
function getSiteRoot() {
const url = new URL(location.href);
url.pathname = url.pathname.replace(/\/[^/]*$/, "/");
return url;
}

function toFetchUrl(projectRelativePath) {
return new URL(projectRelativePath, getSiteRoot()).href;
}

SVG 叠加识别框

根据 dt_polys(四点多边形)在图片上绘制 SVG:

1
2
3
4
5
6
7
8
9
function renderOverlay(imgW, imgH) {
overlayEl.setAttribute("viewBox", `0 0 ${imgW} ${imgH}`);
getRegions().forEach((poly, i) => {
const pg = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
pg.setAttribute("points", poly.map(p => p.join(",")).join(" "));
pg.addEventListener("click", () => setActive(i));
overlayEl.appendChild(pg);
});
}

index.html 完整代码

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OCR 结果预览</title>
<style>
:root {
--bg: #0f1419;
--panel: #1a2332;
--border: #2d3a4f;
--text: #e7ecf3;
--muted: #8b9cb3;
--accent: #3b82f6;
--accent-hover: #2563eb;
--highlight: #f59e0b;
--danger: #ef4444;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
overflow: hidden;
}
body {
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
display: flex;
flex-direction: column;
}
header {
flex-shrink: 0;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
header h1 { font-size: 17px; font-weight: 600; margin-bottom: 12px; }
.controls {
display: flex;
gap: 10px;
align-items: end;
}
.field { flex: 1; min-width: 0; }
.field label {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 4px;
}
.field-row { display: flex; gap: 6px; }
.field-row input[type="text"] {
flex: 1;
min-width: 0;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-size: 13px;
}
.field-row input[type="text"]:focus { outline: none; border-color: var(--accent); }
.btn {
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-size: 13px;
cursor: pointer;
white-space: nowrap;
}
.btn:hover { border-color: var(--accent); }
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
font-weight: 500;
}
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.hint { margin-top: 8px; font-size: 12px; color: var(--muted); }
.hint.error { color: var(--danger); }
.meta { margin-top: 8px; font-size: 12px; color: var(--muted); word-break: break-all; }
main {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1fr 360px;
overflow: hidden;
}
.canvas-wrap {
min-height: 0;
overflow: auto;
padding: 24px;
background: #0a0e14;
display: flex;
align-items: flex-start;
justify-content: center;
}
.placeholder {
color: var(--muted);
font-size: 14px;
padding: 48px;
text-align: center;
}
.stage {
position: relative;
display: none;
line-height: 0;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
}
.stage.visible { display: inline-block; }
.stage img {
display: block;
max-width: 100%;
height: auto;
user-select: none;
}
.stage svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.stage svg polygon {
fill: rgba(59, 130, 246, 0.12);
stroke: rgba(59, 130, 246, 0.85);
stroke-width: 2;
vector-effect: non-scaling-stroke;
pointer-events: all;
cursor: pointer;
transition: fill 0.15s, stroke 0.15s;
}
.stage svg polygon:hover,
.stage svg polygon.active {
fill: rgba(245, 158, 11, 0.25);
stroke: var(--highlight);
stroke-width: 2.5;
}
.sidebar {
min-height: 0;
border-left: 1px solid var(--border);
background: var(--panel);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-head {
padding: 14px;
border-bottom: 1px solid var(--border);
}
.sidebar-head input {
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-size: 13px;
}
.sidebar-head input:focus { outline: none; border-color: var(--accent); }
.stats { margin-top: 8px; font-size: 12px; color: var(--muted); }
.list { flex: 1; min-height: 0; overflow-y: auto; padding: 8px; }
.item {
padding: 10px 12px;
border-radius: 8px;
border: 1px solid transparent;
cursor: pointer;
margin-bottom: 4px;
}
.item:hover { background: rgba(255, 255, 255, 0.04); }
.item.active {
background: rgba(245, 158, 11, 0.12);
border-color: rgba(245, 158, 11, 0.35);
}
.item .idx { font-size: 11px; color: var(--muted); margin-bottom: 4px; }
.item .text { font-size: 14px; line-height: 1.4; word-break: break-all; }
.item .score { margin-top: 4px; font-size: 11px; color: var(--muted); }
.empty { padding: 24px; text-align: center; color: var(--muted); font-size: 13px; }
input[type="file"] { display: none; }
@media (max-width: 900px) {
.controls { flex-direction: column; align-items: stretch; }
main { grid-template-columns: 1fr; grid-template-rows: 1fr 40vh; }
.sidebar { border-left: none; border-top: 1px solid var(--border); }
}
</style>
</head>
<body>
<header>
<h1>OCR 识别结果预览</h1>
<div class="controls">
<div class="field">
<label for="jsonPath">JSON 路径</label>
<div class="field-row">
<input id="jsonPath" type="text" placeholder="例如 output/general_ocr_002_res.json" />
<button class="btn" type="button" id="jsonBrowse">浏览</button>
<input id="jsonFile" type="file" accept=".json,application/json" />
</div>
</div>
<button class="btn btn-primary" type="button" id="renderBtn">渲染</button>
</div>
<div class="hint" id="hint">填写 JSON 路径后点击「渲染」,图片路径将从 JSON 的 input_path 自动读取。需通过本地 HTTP 服务打开(如 uv run python -m http.server 8080)。</div>
<div class="meta" id="meta"></div>
</header>

<main>
<div class="canvas-wrap">
<div class="placeholder" id="placeholder">请设置 JSON 路径,然后点击「渲染」</div>
<div class="stage" id="stage">
<img id="image" alt="OCR 原图" />
<svg id="overlay" viewBox="0 0 1 1" preserveAspectRatio="none"></svg>
</div>
</div>
<aside class="sidebar">
<div class="sidebar-head">
<input id="filter" type="search" placeholder="搜索识别文字..." disabled />
<div class="stats" id="stats"></div>
</div>
<div class="list" id="list">
<div class="empty">暂无数据</div>
</div>
</aside>
</main>

<script>
const jsonPathEl = document.getElementById("jsonPath");
const jsonFileEl = document.getElementById("jsonFile");
const renderBtn = document.getElementById("renderBtn");
const hintEl = document.getElementById("hint");
const metaEl = document.getElementById("meta");
const placeholderEl = document.getElementById("placeholder");
const stageEl = document.getElementById("stage");
const imageEl = document.getElementById("image");
const overlayEl = document.getElementById("overlay");
const listEl = document.getElementById("list");
const statsEl = document.getElementById("stats");
const filterEl = document.getElementById("filter");

let texts = [];
let scores = [];
let polys = [];
let boxes = [];
let polygons = [];
let imageObjectUrl = null;

const PROJECT_DIRS = ["samples", "output", "images", "data", "input"];

function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

function setHint(msg, isError = false) {
hintEl.textContent = msg;
hintEl.classList.toggle("error", isError);
}

function normalizePath(path) {
return String(path).replace(/\\/g, "/");
}

function getSiteRoot() {
const url = new URL(location.href);
const path = url.pathname;
url.pathname = path.endsWith("/") ? path : path.replace(/\/[^/]*$/, "/");
url.search = "";
url.hash = "";
return url;
}

function toFetchUrl(projectRelativePath) {
const norm = normalizePath(projectRelativePath).replace(/^\//, "");
return new URL(norm, getSiteRoot()).href;
}

function resolveImagePath(inputPath, jsonPath) {
if (!inputPath) {
throw new Error("JSON 中缺少 input_path 字段");
}
if (/^https?:\/\//i.test(inputPath)) {
return inputPath;
}

const norm = normalizePath(inputPath);

if (/^[a-zA-Z]:\//.test(norm) || norm.startsWith("/")) {
const lower = norm.toLowerCase();
for (const dir of PROJECT_DIRS) {
const marker = `${dir}/`;
const idx = lower.indexOf(marker);
if (idx >= 0) {
return norm.slice(idx);
}
}
const fileName = norm.split("/").pop();
if (jsonPath) {
const jsonDir = normalizePath(jsonPath).replace(/\/[^/]*$/, "");
return jsonDir ? `${jsonDir}/${fileName}` : fileName;
}
return fileName;
}

if (jsonPath) {
const jsonDir = normalizePath(jsonPath).replace(/\/[^/]*$/, "");
return jsonDir ? `${jsonDir}/${norm}` : norm;
}
return norm;
}

function polyToPoints(poly) {
if (!poly || !poly.length) return "";
return poly.map(p => p.join(",")).join(" ");
}

function boxToPoly(box) {
const [x1, y1, x2, y2] = box;
return [[x1, y1], [x2, y1], [x2, y2], [x1, y2]];
}

function getRegions() {
if (polys.length) return polys;
return boxes.map(boxToPoly);
}

function setActive(index) {
polygons.forEach((pg, i) => pg.classList.toggle("active", i === index));
listEl.querySelectorAll(".item").forEach((el, i) => el.classList.toggle("active", i === index));
const activeItem = listEl.querySelector(".item.active");
if (activeItem) activeItem.scrollIntoView({ block: "nearest" });
}

function renderList(keyword = "") {
listEl.innerHTML = "";
const kw = keyword.trim().toLowerCase();
let visible = 0;

texts.forEach((text, i) => {
if (kw && !String(text).toLowerCase().includes(kw)) return;
visible += 1;
const score = scores[i] != null ? (scores[i] * 100).toFixed(1) : "-";
const item = document.createElement("div");
item.className = "item";
item.innerHTML = `
<div class="idx">#${i + 1}</div>
<div class="text">${escapeHtml(text || "(空)")}</div>
<div class="score">置信度 ${score}%</div>
`;
item.addEventListener("click", () => setActive(i));
listEl.appendChild(item);
});

if (visible === 0) {
listEl.innerHTML = '<div class="empty">无匹配结果</div>';
}
}

function renderOverlay(imgW, imgH) {
overlayEl.setAttribute("viewBox", `0 0 ${imgW} ${imgH}`);
overlayEl.innerHTML = "";
polygons = [];
getRegions().forEach((poly, i) => {
const pg = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
pg.setAttribute("points", polyToPoints(poly));
pg.dataset.index = String(i);
pg.addEventListener("click", () => setActive(i));
overlayEl.appendChild(pg);
polygons.push(pg);
});
}

function applyData(ocrData, imageSrc, jsonPath, imagePath) {
texts = ocrData.rec_texts || [];
scores = ocrData.rec_scores || [];
polys = ocrData.dt_polys || ocrData.rec_polys || [];
boxes = ocrData.rec_boxes || [];

metaEl.textContent = `JSON: ${jsonPath} · 图片: ${imagePath}`;
const count = texts.length;
const avg = scores.length
? (scores.reduce((a, b) => a + b, 0) / scores.length * 100).toFixed(1)
: "-";
statsEl.textContent = `共 ${count} 个文本区域 · 平均置信度 ${avg}%`;
filterEl.disabled = false;

imageEl.onload = () => {
placeholderEl.style.display = "none";
stageEl.classList.add("visible");
renderOverlay(imageEl.naturalWidth, imageEl.naturalHeight);
renderList(filterEl.value);
setHint("渲染完成");
};
imageEl.onerror = () => {
setHint(`图片加载失败: ${imagePath} · ${imageSrc}`, true);
};
imageEl.src = imageSrc;
}

async function loadImage(imagePath) {
if (imageObjectUrl) {
URL.revokeObjectURL(imageObjectUrl);
imageObjectUrl = null;
}
const imageUrl = /^https?:\/\//i.test(imagePath) ? imagePath : toFetchUrl(imagePath);
if (location.protocol === "file:") {
throw new Error("请通过 HTTP 服务打开页面(uv run python -m http.server 8080),不要直接双击 index.html");
}
const res = await fetch(imageUrl);
if (!res.ok) {
throw new Error(`图片加载失败: ${imagePath} (${res.status}) · ${imageUrl}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
imageObjectUrl = url;
return url;
}

async function fetchJson(jsonPath) {
const candidates = [jsonPath];
if (!jsonPath.includes("/") && !jsonPath.includes("\\")) {
candidates.push(`output/${jsonPath}`);
}
for (const candidate of candidates) {
const res = await fetch(toFetchUrl(candidate));
if (res.ok) {
return { data: await res.json(), path: candidate };
}
}
throw new Error(`JSON 加载失败: ${jsonPath}`);
}

async function loadFromJsonPath(jsonPath) {
if (!jsonPath) {
throw new Error("请填写 JSON 路径");
}
setHint("正在加载 JSON...");
const { data: ocrData, path: resolvedJsonPath } = await fetchJson(jsonPath);
const imagePath = resolveImagePath(ocrData.input_path, resolvedJsonPath);
setHint(`正在加载图片: ${imagePath}`);
const imageSrc = await loadImage(imagePath);
applyData(ocrData, imageSrc, resolvedJsonPath, imagePath);
}

async function loadFromJsonFile(jsonFile) {
setHint("正在读取 JSON...");
const text = await jsonFile.text();
const ocrData = JSON.parse(text);
const jsonHint = jsonPathEl.value.trim() || `output/${jsonFile.name}`;
const imagePath = resolveImagePath(ocrData.input_path, jsonHint);
setHint(`正在加载图片: ${imagePath}`);
const imageSrc = await loadImage(imagePath);
applyData(ocrData, imageSrc, jsonFile.name, imagePath);
}

async function render() {
try {
renderBtn.disabled = true;
const jsonFile = jsonFileEl.files[0];
if (jsonFile) {
await loadFromJsonFile(jsonFile);
return;
}
await loadFromJsonPath(jsonPathEl.value.trim());
} catch (err) {
setHint(err.message || String(err), true);
} finally {
renderBtn.disabled = false;
}
}

function initFromQuery() {
const json = new URLSearchParams(location.search).get("json");
if (json) {
jsonPathEl.value = json;
render();
}
}

document.getElementById("jsonBrowse").addEventListener("click", () => jsonFileEl.click());
jsonFileEl.addEventListener("change", () => {
if (jsonFileEl.files[0]) {
jsonPathEl.value = jsonFileEl.files[0].name;
render();
}
});
renderBtn.addEventListener("click", render);
filterEl.addEventListener("input", (e) => renderList(e.target.value));
jsonPathEl.addEventListener("keydown", (e) => { if (e.key === "Enter") render(); });

initFromQuery();
</script>
</body>
</html>

加载流程:

1
2
填写 JSON 路径 → fetch JSON → 读取 input_path → fetch 图片
→ 绘制 SVG 框 → 渲染右侧列表

踩坑记录

1. Paddle 版本与 PP-OCRv6 不兼容

现象paddlepaddle-gpu==3.0.0 加载 PP-OCRv6 模型报错:

1
ValueError: Type of attribute: strides is not right.

原因:PP-OCRv6 模型导出使用的 Paddle 版本 ≥ 3.1,与 3.0.x 不兼容。

解决:升级到 paddlepaddle-gpu==3.2.2

2. Windows 缺少 cuDNN DLL

现象

1
cudnn64_8.dll is not configured correctly

原因:Windows 下 paddlepaddle-gpu 不会自动安装 NVIDIA 运行时。

解决uv sync --group gpu 安装 nvidia-cudnn-cu11 等包。

使用流程总结

1
2
3
4
5
6
7
8
9
10
11
12
# 1. 安装
uv sync --group gpu

# 2. 验证 GPU
uv run verify-env

# 3. OCR 推理
uv run run-ocr --html

# 4. 启动可视化
uv run python -m http.server 8080
# 打开 http://127.0.0.1:8080/index.html?json=output/general_ocr_002_res.json

参考链接