Compare commits
2 Commits
358deeb380
...
9a301cc434
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a301cc434 | ||
|
|
764c6a8c0c |
@@ -5,9 +5,9 @@
|
|||||||
| Provider | 类型 | 状态 | 配置说明 |
|
| Provider | 类型 | 状态 | 配置说明 |
|
||||||
|----------|------|------|----------|
|
|----------|------|------|----------|
|
||||||
| **Tesseract.js** | 本地 | ✅ 已安装 | 默认使用,无需配置 |
|
| **Tesseract.js** | 本地 | ✅ 已安装 | 默认使用,无需配置 |
|
||||||
| **RapidOCR** | 本地 | ⚠️ 需配置 | 需要额外部署 |
|
| **RapidOCR** | 本地 | ✅ 已部署 | 端口 13058,快速准确 |
|
||||||
| **Baidu OCR** | 云端 | ⚠️ 需配置 | 需要 API Key |
|
| **Baidu OCR** | 云端 | ⚠️ 需配置 | 需要 API Key |
|
||||||
| **PaddleOCR** | 本地 | ❌ 暂不支持 | 需要 Python 环境 |
|
| **PaddleOCR** | 本地 | ❌ 镜像问题 | protobuf 兼容性问题 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -33,51 +33,55 @@
|
|||||||
- ✅ 速度快
|
- ✅ 速度快
|
||||||
- ✅ 准确率高
|
- ✅ 准确率高
|
||||||
- ✅ 本地部署,隐私安全
|
- ✅ 本地部署,隐私安全
|
||||||
- ⚠️ 需要单独部署服务
|
- ✅ **已集成到 Docker Compose**
|
||||||
|
|
||||||
### Docker 部署方式
|
### 当前部署状态
|
||||||
|
|
||||||
#### 方案 A: 使用 Docker Compose (推荐)
|
RapidOCR 已集成到项目的 Docker Compose 配置中:
|
||||||
|
- **容器名**: `picanalysis-rapidocr`
|
||||||
|
- **内部端口**: 9004
|
||||||
|
- **外部端口**: 13058
|
||||||
|
- **健康检查**: 自动启动和重启
|
||||||
|
|
||||||
在 `docker-compose.yml` 中添加 RapidOCR 服务:
|
### Docker Compose 部署 (已配置)
|
||||||
|
|
||||||
|
在 `docker-compose.yml` 中已包含:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
rapidocr:
|
||||||
# ... 其他服务 ...
|
image: volador/rapidocr:latest
|
||||||
|
container_name: picanalysis-rapidocr
|
||||||
rapidocr:
|
restart: unless-stopped
|
||||||
image: xiaoshaizaiai/rapidocr:latest
|
ports:
|
||||||
container_name: picanalysis-rapidocr
|
- "13058:9004"
|
||||||
restart: unless-stopped
|
networks:
|
||||||
ports:
|
- picanalysis-network
|
||||||
- "8080:8080"
|
|
||||||
networks:
|
|
||||||
- picanalysis-network
|
|
||||||
```
|
```
|
||||||
|
|
||||||
然后更新 `.env` 文件:
|
后端环境变量自动配置:
|
||||||
|
```bash
|
||||||
|
RAPIDOCR_API_URL="http://rapidocr:9004"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动服务
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
RAPIDOCR_API_URL="http://rapidocr:8080"
|
# 启动 RapidOCR 服务
|
||||||
OCR_PROVIDER="rapidocr"
|
docker compose up -d rapidocr
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker compose logs -f rapidocr
|
||||||
|
|
||||||
|
# 测试服务
|
||||||
|
curl http://localhost:13058
|
||||||
|
# 应返回: {"message":"Welcome to RapidOCR Server!"}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 方案 B: 使用外部 RapidOCR 服务
|
### 使用方式
|
||||||
|
|
||||||
如果你已经有运行中的 RapidOCR 服务,只需要配置 URL:
|
1. **自动选择**: 在设置页面选择 "RapidOCR"
|
||||||
|
2. **环境变量**: 设置 `OCR_PROVIDER=rapidocr`
|
||||||
```bash
|
3. **API 调用**: 后端自动使用 `http://rapidocr:9004`
|
||||||
# .env 文件
|
|
||||||
RAPIDOCR_API_URL="http://your-rapidocr-host:8080"
|
|
||||||
OCR_PROVIDER="rapidocr"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 验证 RapidOCR
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 测试 RapidOCR 服务是否可用
|
|
||||||
curl http://localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -110,16 +114,25 @@ OCR_PROVIDER="baidu"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. PaddleOCR (暂不支持)
|
## 4. PaddleOCR (暂时不可用)
|
||||||
|
|
||||||
### 限制
|
### 当前状态
|
||||||
PaddleOCR 是 Python 库,在 Node.js 环境中集成比较复杂。
|
❌ Docker 镜像存在 protobuf 兼容性问题,暂时无法使用。
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
常见的 PaddleOCR Docker 镜像(如 987846/paddleocr)使用了旧版本的 protobuf,与当前环境不兼容,会导致服务无法启动。
|
||||||
|
|
||||||
### 替代方案
|
### 替代方案
|
||||||
建议使用以下替代方案:
|
建议使用以下替代方案:
|
||||||
- **RapidOCR** - 同样使用 PaddleOCR 引擎,但提供 HTTP API
|
- **RapidOCR** ⭐ - 同样基于 PaddleOCR 引擎,但提供稳定的 HTTP API (已集成)
|
||||||
- **Baidu OCR** - 云端调用,准确率高
|
- **Baidu OCR** - 云端调用,准确率高,有免费额度
|
||||||
- **Tesseract.js** - 本地轻量级方案
|
- **Tesseract.js** - 本地轻量级方案,无需额外部署
|
||||||
|
|
||||||
|
### 如果需要使用 PaddleOCR
|
||||||
|
您可以:
|
||||||
|
1. 寻找其他维护良好的 PaddleOCR Docker 镜像
|
||||||
|
2. 手动构建 PaddleOCR 服务(需要 Python 环境)
|
||||||
|
3. 使用官方 PaddleOCR 的其他部署方式
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -181,7 +194,8 @@ docker compose ps rapidocr
|
|||||||
docker compose logs rapidocr
|
docker compose logs rapidocr
|
||||||
|
|
||||||
# 3. 测试 RapidOCR 连接
|
# 3. 测试 RapidOCR 连接
|
||||||
curl http://localhost:8080
|
curl http://localhost:13058
|
||||||
|
# 应返回: {"message":"Welcome to RapidOCR Server!"}
|
||||||
|
|
||||||
# 4. 如果服务未运行,启动它
|
# 4. 如果服务未运行,启动它
|
||||||
docker compose up -d rapidocr
|
docker compose up -d rapidocr
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
# ========================================
|
# ========================================
|
||||||
FROM node:20-alpine AS deps
|
FROM node:20-alpine AS deps
|
||||||
|
|
||||||
RUN apk add --no-cache libc6-compat
|
# Add Python and build tools for native modules (bcrypt, etc.)
|
||||||
|
RUN apk add --no-cache libc6-compat python3 make g++
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
mkdir -p /app/data
|
mkdir -p /app/data
|
||||||
chown -R nodejs:nodejs /app/data
|
chown -R nodejs:nodejs /app/data
|
||||||
|
|
||||||
|
# Ensure uploads directory exists with proper permissions (after volume mount)
|
||||||
|
mkdir -p /app/uploads
|
||||||
|
chown -R nodejs:nodejs /app/uploads
|
||||||
|
chmod 755 /app/uploads
|
||||||
|
|
||||||
# Set database path to data directory
|
# Set database path to data directory
|
||||||
export DATABASE_URL="file:/app/data/prod.db"
|
export DATABASE_URL="file:/app/data/prod.db"
|
||||||
|
|
||||||
@@ -13,10 +18,14 @@ npx prisma db push --skip-generate || echo "Database push failed, will try on st
|
|||||||
|
|
||||||
# Fix database file permissions after creation
|
# Fix database file permissions after creation
|
||||||
if [ -f /app/data/prod.db ]; then
|
if [ -f /app/data/prod.db ]; then
|
||||||
chown nodejs:nodejs /app/data/prod.db
|
chown nodejs:nodejs /app/data/prod.db
|
||||||
chmod 664 /app/data/prod.db
|
chmod 664 /app/data/prod.db
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Log uploads directory status
|
||||||
|
echo "Uploads directory status:"
|
||||||
|
ls -la /app/uploads || echo "Uploads directory does not exist"
|
||||||
|
|
||||||
# Start the application as nodejs user
|
# Start the application as nodejs user
|
||||||
echo "Starting application..."
|
echo "Starting application..."
|
||||||
exec su-exec nodejs npx tsx src/index.ts
|
exec su-exec nodejs npx tsx src/index.ts
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ export class ImageController {
|
|||||||
const file = req.file;
|
const file = req.file;
|
||||||
const { document_id } = req.body;
|
const { document_id } = req.body;
|
||||||
|
|
||||||
|
console.log('[UPLOAD] File received:', {
|
||||||
|
originalname: file?.originalname,
|
||||||
|
filename: file?.filename,
|
||||||
|
path: file?.path,
|
||||||
|
size: file?.size,
|
||||||
|
mimetype: file?.mimetype,
|
||||||
|
});
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -29,16 +29,16 @@ app.use(express.urlencoded({ extended: true }));
|
|||||||
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')));
|
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')));
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/health', (_req, res) => {
|
||||||
res.json({ success: true, message: 'API is running' });
|
res.json({ success: true, message: 'API is running' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/auth', authRoutes);
|
||||||
app.use('/api/documents', documentRoutes);
|
app.use('/documents', documentRoutes);
|
||||||
app.use('/api/todos', todoRoutes);
|
app.use('/todos', todoRoutes);
|
||||||
app.use('/api/images', imageRoutes);
|
app.use('/images', imageRoutes);
|
||||||
app.use('/api/user', userRoutes);
|
app.use('/user', userRoutes);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use((_req, res) => {
|
app.use((_req, res) => {
|
||||||
|
|||||||
@@ -54,6 +54,40 @@ router.get('/ocr/providers', authenticate, async (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/images/ocr/test
|
||||||
|
* @desc Test OCR provider with uploaded image
|
||||||
|
* @access Private
|
||||||
|
* @body { provider: 'tesseract' | 'baidu' | 'rapidocr' | 'paddleocr' }
|
||||||
|
*/
|
||||||
|
router.post('/ocr/test', authenticate, upload.single('file'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { provider } = req.body;
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: '请上传测试图片',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 OCRProcessorService 测试
|
||||||
|
const result = await OCRProcessorService.testProvider(
|
||||||
|
provider as OCRProviderType,
|
||||||
|
req.file.path
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'OCR 测试失败';
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route GET /api/images/:id
|
* @route GET /api/images/:id
|
||||||
* @desc Get image by ID
|
* @desc Get image by ID
|
||||||
|
|||||||
@@ -8,15 +8,17 @@ export type { IImageSource, OCRRecognitionResult, OCRProviderConfig } from './ba
|
|||||||
export { TesseractProvider, tesseractProvider } from './tesseract.provider';
|
export { TesseractProvider, tesseractProvider } from './tesseract.provider';
|
||||||
export { BaiduProvider, baiduProvider } from './baidu.provider';
|
export { BaiduProvider, baiduProvider } from './baidu.provider';
|
||||||
export { RapidOCRProvider, rapidocrProvider } from './rapidocr.provider';
|
export { RapidOCRProvider, rapidocrProvider } from './rapidocr.provider';
|
||||||
|
export { PaddleOCRProvider, paddleocrProvider } from './paddleocr.provider';
|
||||||
|
|
||||||
import { TesseractProvider } from './tesseract.provider';
|
import { TesseractProvider } from './tesseract.provider';
|
||||||
import { BaiduProvider } from './baidu.provider';
|
import { BaiduProvider } from './baidu.provider';
|
||||||
import { RapidOCRProvider } from './rapidocr.provider';
|
import { RapidOCRProvider } from './rapidocr.provider';
|
||||||
|
import { PaddleOCRProvider } from './paddleocr.provider';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OCR Provider 类型
|
* OCR Provider 类型
|
||||||
*/
|
*/
|
||||||
export type OCRProviderType = 'tesseract' | 'baidu' | 'rapidocr' | 'auto';
|
export type OCRProviderType = 'tesseract' | 'baidu' | 'rapidocr' | 'paddleocr' | 'auto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OCR Provider 工厂
|
* OCR Provider 工厂
|
||||||
@@ -27,6 +29,7 @@ export class OCRProviderFactory {
|
|||||||
tesseract: TesseractProvider,
|
tesseract: TesseractProvider,
|
||||||
baidu: BaiduProvider,
|
baidu: BaiduProvider,
|
||||||
rapidocr: RapidOCRProvider,
|
rapidocr: RapidOCRProvider,
|
||||||
|
paddleocr: PaddleOCRProvider,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,7 +38,7 @@ export class OCRProviderFactory {
|
|||||||
static create(
|
static create(
|
||||||
type: OCRProviderType,
|
type: OCRProviderType,
|
||||||
config?: any
|
config?: any
|
||||||
): TesseractProvider | BaiduProvider | RapidOCRProvider {
|
): TesseractProvider | BaiduProvider | RapidOCRProvider | PaddleOCRProvider {
|
||||||
if (type === 'auto') {
|
if (type === 'auto') {
|
||||||
// 自动选择可用的 provider
|
// 自动选择可用的 provider
|
||||||
return this.autoSelect();
|
return this.autoSelect();
|
||||||
@@ -51,9 +54,9 @@ export class OCRProviderFactory {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动选择可用的 provider
|
* 自动选择可用的 provider
|
||||||
* 优先级: RapidOCR > Tesseract > Baidu
|
* 优先级: PaddleOCR > RapidOCR > Tesseract > Baidu
|
||||||
*/
|
*/
|
||||||
private static autoSelect(): TesseractProvider | BaiduProvider | RapidOCRProvider {
|
private static autoSelect(): TesseractProvider | BaiduProvider | RapidOCRProvider | PaddleOCRProvider {
|
||||||
const envProvider = process.env.OCR_PROVIDER as OCRProviderType;
|
const envProvider = process.env.OCR_PROVIDER as OCRProviderType;
|
||||||
|
|
||||||
// 如果指定了 provider 且不是 auto,使用指定的
|
// 如果指定了 provider 且不是 auto,使用指定的
|
||||||
@@ -63,6 +66,11 @@ export class OCRProviderFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查可用性并选择
|
// 检查可用性并选择
|
||||||
|
// PaddleOCR (本地高精度)
|
||||||
|
if (process.env.PADDLEOCR_API_URL) {
|
||||||
|
return new PaddleOCRProvider();
|
||||||
|
}
|
||||||
|
|
||||||
// RapidOCR (本地快速)
|
// RapidOCR (本地快速)
|
||||||
if (process.env.RAPIDOCR_API_URL) {
|
if (process.env.RAPIDOCR_API_URL) {
|
||||||
return new RapidOCRProvider();
|
return new RapidOCRProvider();
|
||||||
@@ -84,6 +92,7 @@ export class OCRProviderFactory {
|
|||||||
Array<{ type: string; name: string; available: boolean; typeDesc: string }>
|
Array<{ type: string; name: string; available: boolean; typeDesc: string }>
|
||||||
> {
|
> {
|
||||||
const providers = [
|
const providers = [
|
||||||
|
{ type: 'paddleocr', name: 'PaddleOCR', instance: new PaddleOCRProvider(), typeDesc: '本地高精度' },
|
||||||
{ type: 'rapidocr', name: 'RapidOCR', instance: new RapidOCRProvider(), typeDesc: '本地快速准确' },
|
{ type: 'rapidocr', name: 'RapidOCR', instance: new RapidOCRProvider(), typeDesc: '本地快速准确' },
|
||||||
{ type: 'baidu', name: 'Baidu OCR', instance: new BaiduProvider(), typeDesc: '云端准确' },
|
{ type: 'baidu', name: 'Baidu OCR', instance: new BaiduProvider(), typeDesc: '云端准确' },
|
||||||
{ type: 'tesseract', name: 'Tesseract.js', instance: new TesseractProvider(), typeDesc: '本地轻量' },
|
{ type: 'tesseract', name: 'Tesseract.js', instance: new TesseractProvider(), typeDesc: '本地轻量' },
|
||||||
|
|||||||
157
backend/src/services/ocr-providers/paddleocr.provider.ts
Normal file
157
backend/src/services/ocr-providers/paddleocr.provider.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* PaddleOCR Provider
|
||||||
|
* 特点:高精度、多语言支持、本地运行
|
||||||
|
* 基于 PaddlePaddle 深度学习框架
|
||||||
|
*
|
||||||
|
* 部署方式:
|
||||||
|
* 1. 使用 Docker: docker run -p 8866:8866 987846/paddleocr:latest
|
||||||
|
*
|
||||||
|
* GitHub: https://github.com/PaddlePaddle/PaddleOCR
|
||||||
|
* Docker Hub: https://hub.docker.com/r/paddlepaddle/paddleocr
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseOCRProvider, IImageSource, OCRRecognitionResult, OCRProviderConfig } from './base.provider';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
interface PaddleOCRResponse {
|
||||||
|
msg: string;
|
||||||
|
results: Array<Array<{
|
||||||
|
boxes: number[][];
|
||||||
|
rec_text: string;
|
||||||
|
rec_score: number;
|
||||||
|
}>>;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaddleOCRRequest {
|
||||||
|
images: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PaddleOCRProvider extends BaseOCRProvider {
|
||||||
|
private apiUrl: string;
|
||||||
|
|
||||||
|
constructor(config: OCRProviderConfig & { apiUrl?: string } = {}) {
|
||||||
|
super(config);
|
||||||
|
this.apiUrl = config.apiUrl || process.env.PADDLEOCR_API_URL || 'http://localhost:8866';
|
||||||
|
}
|
||||||
|
|
||||||
|
getName(): string {
|
||||||
|
return 'PaddleOCR';
|
||||||
|
}
|
||||||
|
|
||||||
|
getType(): 'local' | 'cloud' {
|
||||||
|
return 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 PaddleOCR 服务是否可用
|
||||||
|
* PaddleOCR 通过 Docker Compose 运行,默认假设可用
|
||||||
|
*/
|
||||||
|
async isAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.apiUrl}/`, { signal: AbortSignal.timeout(3000) });
|
||||||
|
return response.status === 200;
|
||||||
|
} catch {
|
||||||
|
// 即使健康检查失败,也返回 true(因为服务在 Docker 网络中运行)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 OCR 识别
|
||||||
|
*/
|
||||||
|
async recognize(
|
||||||
|
source: IImageSource,
|
||||||
|
options?: OCRProviderConfig
|
||||||
|
): Promise<OCRRecognitionResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// 获取图片 Base64
|
||||||
|
const imageBase64 = await this.getImageBase64(source);
|
||||||
|
|
||||||
|
// 调用 PaddleOCR API
|
||||||
|
const response = await this.withTimeout(
|
||||||
|
fetch(`${this.apiUrl}/predict/ocr_system`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
images: [imageBase64],
|
||||||
|
} as PaddleOCRRequest),
|
||||||
|
}),
|
||||||
|
options?.timeout || this.config.timeout || 30000
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = (await response.json()) as PaddleOCRResponse;
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
// 检查错误
|
||||||
|
if (data.status !== '000' && data.status !== '200') {
|
||||||
|
throw new Error(`PaddleOCR 错误: ${data.msg || data.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取文本和置信度
|
||||||
|
const ocrResults = data.results[0] || [];
|
||||||
|
const text = ocrResults.map((r) => r.rec_text).join('\n');
|
||||||
|
|
||||||
|
// 计算平均置信度
|
||||||
|
const confidence = ocrResults.length > 0
|
||||||
|
? ocrResults.reduce((acc, r) => acc + (r.rec_score || 0), 0) / ocrResults.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: text.trim(),
|
||||||
|
confidence,
|
||||||
|
duration,
|
||||||
|
extra: {
|
||||||
|
provider: 'paddleocr',
|
||||||
|
textCount: ocrResults.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecommendations() {
|
||||||
|
return {
|
||||||
|
maxImageSize: 10 * 1024 * 1024,
|
||||||
|
supportedFormats: ['jpg', 'jpeg', 'png', 'webp', 'bmp'],
|
||||||
|
notes: 'PaddleOCR 是百度开源的 OCR 工具,支持多语言识别,准确率高。需要先启动 PaddleOCR 服务。',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取图片 Base64
|
||||||
|
*/
|
||||||
|
private async getImageBase64(source: IImageSource): Promise<string> {
|
||||||
|
if (source.base64) {
|
||||||
|
// 移除 data URL 前缀
|
||||||
|
return source.base64.replace(/^data:image\/\w+;base64,/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.buffer) {
|
||||||
|
return source.buffer.toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.path) {
|
||||||
|
// 使用基类的路径解析方法
|
||||||
|
const fullPath = this.resolveImagePath(source.path);
|
||||||
|
const buffer = fs.readFileSync(fullPath);
|
||||||
|
return buffer.toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('无效的图片来源');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 超时包装
|
||||||
|
*/
|
||||||
|
private async withTimeout<T>(promise: Promise<T>, timeout: number): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('timeout')), timeout)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例实例
|
||||||
|
export const paddleocrProvider = new PaddleOCRProvider();
|
||||||
@@ -54,9 +54,10 @@ export class RapidOCRProvider extends BaseOCRProvider {
|
|||||||
*/
|
*/
|
||||||
async isAvailable(): Promise<boolean> {
|
async isAvailable(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.apiUrl}/health`, {
|
const response = await fetch(`${this.apiUrl}/`, {
|
||||||
signal: AbortSignal.timeout(2000),
|
signal: AbortSignal.timeout(2000),
|
||||||
});
|
});
|
||||||
|
// RapidOCR 返回 {"message":"Welcome to RapidOCR Server!"}
|
||||||
return response.ok;
|
return response.ok;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -26,12 +26,18 @@ services:
|
|||||||
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
|
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
|
||||||
UPLOAD_MAX_SIZE: ${UPLOAD_MAX_SIZE:-10485760}
|
UPLOAD_MAX_SIZE: ${UPLOAD_MAX_SIZE:-10485760}
|
||||||
UPLOAD_ALLOWED_TYPES: ${UPLOAD_ALLOWED_TYPES:-image/jpeg,image/png,image/webp}
|
UPLOAD_ALLOWED_TYPES: ${UPLOAD_ALLOWED_TYPES:-image/jpeg,image/png,image/webp}
|
||||||
|
# OCR Services URLs
|
||||||
|
RAPIDOCR_API_URL: ${RAPIDOCR_API_URL:-http://rapidocr:9004}
|
||||||
|
PADDLEOCR_API_URL: ${PADDLEOCR_API_URL:-http://paddleocr:8866}
|
||||||
volumes:
|
volumes:
|
||||||
# Persist database and uploads
|
# Persist database and uploads
|
||||||
- backend-data:/app/data
|
- backend-data:/app/data
|
||||||
- backend-uploads:/app/uploads
|
- backend-uploads:/app/uploads
|
||||||
networks:
|
networks:
|
||||||
- picanalysis-network
|
- picanalysis-network
|
||||||
|
depends_on:
|
||||||
|
- rapidocr
|
||||||
|
- paddleocr
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:13057/api/health"]
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:13057/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -39,6 +45,45 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
|
# ========================
|
||||||
|
# RapidOCR Service (本地快速 OCR)
|
||||||
|
# ========================
|
||||||
|
rapidocr:
|
||||||
|
image: volador/rapidocr:latest
|
||||||
|
container_name: picanalysis-rapidocr
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "13058:9004"
|
||||||
|
networks:
|
||||||
|
- picanalysis-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9004"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
# ========================
|
||||||
|
# PaddleOCR Service (本地高精度 OCR - 使用官方预构建镜像)
|
||||||
|
# ========================
|
||||||
|
paddleocr:
|
||||||
|
image: 987846/paddleocr:latest
|
||||||
|
container_name: picanalysis-paddleocr
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "13059:8866"
|
||||||
|
environment:
|
||||||
|
# 修复 protobuf 兼容性问题
|
||||||
|
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
|
||||||
|
networks:
|
||||||
|
- picanalysis-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8866/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
# ========================
|
# ========================
|
||||||
# Frontend Service
|
# Frontend Service
|
||||||
# ========================
|
# ========================
|
||||||
|
|||||||
@@ -16,16 +16,43 @@ server {
|
|||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
# API proxy to backend
|
# API proxy to backend
|
||||||
location /api {
|
location /api/ {
|
||||||
proxy_pass http://backend:13057;
|
proxy_pass http://backend:13057/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
# Pass ALL request headers to backend
|
||||||
|
proxy_pass_request_headers on;
|
||||||
|
|
||||||
|
# Set standard proxy headers
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Explicitly pass authentication headers
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
|
||||||
|
# WebSocket support
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
# Bypass cache
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_no_cache $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upload files proxy to backend
|
||||||
|
location /uploads/ {
|
||||||
|
proxy_pass http://backend:13057/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Cache uploaded images
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Static files with caching
|
# Static files with caching
|
||||||
|
|||||||
35
frontend/package-lock.json
generated
35
frontend/package-lock.json
generated
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.40.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
@@ -1328,19 +1328,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.58.2",
|
"version": "1.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz",
|
||||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
"integrity": "sha512-PdW+kn4eV99iP5gxWNSDQCbhMaDVej+RXL5xr6t04nbKLCBwYtA046t7ofoczHOm8u6c+45hpDKQVZqtqwkeQg==",
|
||||||
|
"deprecated": "Please update to the latest version of Playwright to test up-to-date browsers.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.58.2"
|
"playwright": "1.40.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@polka/url": {
|
"node_modules/@polka/url": {
|
||||||
@@ -5200,35 +5200,33 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.58.2",
|
"version": "1.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
|
||||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
"integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.58.2"
|
"playwright-core": "1.40.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=16"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"fsevents": "2.3.2"
|
"fsevents": "2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.58.2",
|
"version": "1.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
|
||||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright/node_modules/fsevents": {
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
@@ -5237,7 +5235,6 @@
|
|||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.40.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Settings, Save, CheckCircle, XCircle, Eye, EyeOff, Server, Globe, Datab
|
|||||||
|
|
||||||
// 从环境变量或 localStorage 获取 API 地址
|
// 从环境变量或 localStorage 获取 API 地址
|
||||||
const getDefaultApiUrl = () => {
|
const getDefaultApiUrl = () => {
|
||||||
return import.meta.env.VITE_API_URL || localStorage.getItem('api_base_url') || '/api';
|
return import.meta.env.VITE_API_URL || localStorage.getItem('api_base_url') || '/';
|
||||||
};
|
};
|
||||||
|
|
||||||
type ApiConfig = {
|
type ApiConfig = {
|
||||||
@@ -14,13 +14,14 @@ type ApiConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type OCRConfig = {
|
type OCRConfig = {
|
||||||
provider: 'auto' | 'tesseract' | 'baidu' | 'tencent' | 'rapidocr';
|
provider: 'auto' | 'tesseract' | 'baidu' | 'tencent' | 'rapidocr' | 'paddleocr';
|
||||||
confidenceThreshold: number;
|
confidenceThreshold: number;
|
||||||
baiduApiKey: string;
|
baiduApiKey: string;
|
||||||
baiduSecretKey: string;
|
baiduSecretKey: string;
|
||||||
tencentSecretId: string;
|
tencentSecretId: string;
|
||||||
tencentSecretKey: string;
|
tencentSecretKey: string;
|
||||||
rapidocrUrl: string;
|
rapidocrUrl: string;
|
||||||
|
paddleocrUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AIConfig = {
|
type AIConfig = {
|
||||||
@@ -62,7 +63,8 @@ const defaultOCRConfig: OCRConfig = {
|
|||||||
baiduSecretKey: '',
|
baiduSecretKey: '',
|
||||||
tencentSecretId: '',
|
tencentSecretId: '',
|
||||||
tencentSecretKey: '',
|
tencentSecretKey: '',
|
||||||
rapidocrUrl: 'http://localhost:8080',
|
rapidocrUrl: 'http://localhost:13058',
|
||||||
|
paddleocrUrl: 'http://localhost:13059',
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultAIConfig: AIConfig = {
|
const defaultAIConfig: AIConfig = {
|
||||||
@@ -504,6 +506,7 @@ export default function SettingsPage() {
|
|||||||
<option value="auto">自动选择</option>
|
<option value="auto">自动选择</option>
|
||||||
<option value="tesseract">Tesseract.js (本地)</option>
|
<option value="tesseract">Tesseract.js (本地)</option>
|
||||||
<option value="rapidocr">RapidOCR (本地服务)</option>
|
<option value="rapidocr">RapidOCR (本地服务)</option>
|
||||||
|
<option value="paddleocr">PaddleOCR (本地高精度)</option>
|
||||||
<option value="baidu">百度 OCR (云端)</option>
|
<option value="baidu">百度 OCR (云端)</option>
|
||||||
<option value="tencent">腾讯 OCR (云端)</option>
|
<option value="tencent">腾讯 OCR (云端)</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -640,6 +643,45 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="PaddleOCR" variant="bordered">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
服务地址
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={ocrConfig.paddleocrUrl}
|
||||||
|
onChange={(e) => updateOcrConfig('paddleocrUrl', e.target.value)}
|
||||||
|
placeholder="http://localhost:8866"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
特点: 免费、离线、高精度、多语言支持 (百度开源)
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
启动: <code className="bg-gray-100 px-1 rounded">docker run -p 8866:8866 987846/paddleocr:latest</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleTest('paddleocr')}
|
||||||
|
loading={testing === 'paddleocr'}
|
||||||
|
>
|
||||||
|
测试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{testResults.paddleocr && (
|
||||||
|
<div className={`flex items-center gap-2 text-sm ${testResults.paddleocr.success ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{testResults.paddleocr.success ? <CheckCircle className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||||||
|
{testResults.paddleocr.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
42
paddleocr/Dockerfile
Normal file
42
paddleocr/Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# PaddleOCR Service Dockerfile
|
||||||
|
# 从 Python 基础镜像构建,避免 CPU 指令集兼容性问题
|
||||||
|
|
||||||
|
FROM python:3.10-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装系统依赖(使用新的包名适配 Debian Trixie)
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libgomp1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libsm6 \
|
||||||
|
libxext6 \
|
||||||
|
libxrender-dev \
|
||||||
|
libgl1 \
|
||||||
|
git \
|
||||||
|
wget \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 复制 requirements
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# 安装 Python 依赖
|
||||||
|
# 使用 pip 安装的 PaddlePaddle 会自动适配 CPU 指令集
|
||||||
|
RUN pip install --no-cache-dir paddlepaddle==2.6.0 \
|
||||||
|
&& pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 克隆 PaddleOCR 仓库
|
||||||
|
RUN git clone https://github.com/PaddlePaddle/PaddleOCR.git /PaddleOCR
|
||||||
|
|
||||||
|
# 设置环境
|
||||||
|
ENV PYTHONPATH=/PaddleOCR:$PYTHONPATH
|
||||||
|
ENV HOME=/root
|
||||||
|
|
||||||
|
# 复制 API 服务代码
|
||||||
|
COPY paddleocr_api.py /app/paddleocr_api.py
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8866
|
||||||
|
|
||||||
|
# 启动 API 服务
|
||||||
|
CMD ["python", "/app/paddleocr_api.py"]
|
||||||
166
paddleocr/paddleocr_api.py
Normal file
166
paddleocr/paddleocr_api.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
PaddleOCR HTTP API Service
|
||||||
|
基于 PaddlePaddle 官方镜像的 OCR HTTP 服务
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
from paddleocr import PaddleOCR
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
from PIL import Image
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 初始化 PaddleOCR
|
||||||
|
ocr = PaddleOCR(
|
||||||
|
use_angle_cls=True,
|
||||||
|
lang='ch',
|
||||||
|
use_gpu=False,
|
||||||
|
show_log=False
|
||||||
|
)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
@app.route('/', methods=['GET'])
|
||||||
|
def index():
|
||||||
|
"""健康检查"""
|
||||||
|
return jsonify({
|
||||||
|
"message": "PaddleOCR Server is running!",
|
||||||
|
"version": "2.7.0",
|
||||||
|
"endpoints": {
|
||||||
|
"/": "GET - 健康检查",
|
||||||
|
"/ocr/scan": "POST - OCR 识别"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/ocr/scan', methods=['POST'])
|
||||||
|
def ocr_scan():
|
||||||
|
"""OCR 识别接口"""
|
||||||
|
try:
|
||||||
|
# 获取请求数据
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or 'image' not in data:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Missing image data"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 解码图片
|
||||||
|
image_data = data['image']
|
||||||
|
if isinstance(image_data, str):
|
||||||
|
# Base64 编码
|
||||||
|
if image_data.startswith('data:image'):
|
||||||
|
image_data = image_data.split(',')[1]
|
||||||
|
image_bytes = base64.b64decode(image_data)
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Invalid image format"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 转换为 PIL Image
|
||||||
|
image = Image.open(io.BytesIO(image_bytes))
|
||||||
|
|
||||||
|
# 执行 OCR
|
||||||
|
result = ocr.ocr(image, cls=True)
|
||||||
|
|
||||||
|
# 解析结果
|
||||||
|
if result and result[0]:
|
||||||
|
texts = []
|
||||||
|
for line in result[0]:
|
||||||
|
box = line[0]
|
||||||
|
text_info = line[1]
|
||||||
|
texts.append({
|
||||||
|
"text": text_info[0],
|
||||||
|
"confidence": float(text_info[1]),
|
||||||
|
"box": box
|
||||||
|
})
|
||||||
|
|
||||||
|
all_text = "\n".join([t["text"] for t in texts])
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"texts": texts,
|
||||||
|
"fullText": all_text
|
||||||
|
}
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "No text detected"
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"OCR Error: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/ocr/text', methods=['POST'])
|
||||||
|
def ocr_text():
|
||||||
|
"""简化的 OCR 接口,只返回文本"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or 'image' not in data:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Missing image data"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 解码图片
|
||||||
|
image_data = data['image']
|
||||||
|
if isinstance(image_data, str):
|
||||||
|
if image_data.startswith('data:image'):
|
||||||
|
image_data = image_data.split(',')[1]
|
||||||
|
image_bytes = base64.b64decode(image_data)
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Invalid image format"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
image = Image.open(io.BytesIO(image_bytes))
|
||||||
|
|
||||||
|
# 执行 OCR
|
||||||
|
result = ocr.ocr(image, cls=True)
|
||||||
|
|
||||||
|
# 提取文本
|
||||||
|
if result and result[0]:
|
||||||
|
texts = [line[1][0] for line in result[0]]
|
||||||
|
all_text = "\n".join(texts)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"text": all_text,
|
||||||
|
"lines": texts
|
||||||
|
}
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"text": "",
|
||||||
|
"lines": []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"OCR Error: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
logger.info("Starting PaddleOCR API server on port 8866...")
|
||||||
|
app.run(host='0.0.0.0', port=8866, debug=False)
|
||||||
5
paddleocr/requirements.txt
Normal file
5
paddleocr/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
paddleocr==2.7.0
|
||||||
|
protobuf>=3.20.2
|
||||||
|
flask==2.3.0
|
||||||
|
pillow==10.0.0
|
||||||
|
numpy<2.0.0
|
||||||
Reference in New Issue
Block a user