diff --git a/OCR_SETUP_GUIDE.md b/OCR_SETUP_GUIDE.md index e36a929..adc10e1 100644 --- a/OCR_SETUP_GUIDE.md +++ b/OCR_SETUP_GUIDE.md @@ -5,9 +5,9 @@ | Provider | 类型 | 状态 | 配置说明 | |----------|------|------|----------| | **Tesseract.js** | 本地 | ✅ 已安装 | 默认使用,无需配置 | -| **RapidOCR** | 本地 | ⚠️ 需配置 | 需要额外部署 | +| **RapidOCR** | 本地 | ✅ 已部署 | 端口 13058,快速准确 | | **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 -services: - # ... 其他服务 ... - - rapidocr: - image: xiaoshaizaiai/rapidocr:latest - container_name: picanalysis-rapidocr - restart: unless-stopped - ports: - - "8080:8080" - networks: - - picanalysis-network +rapidocr: + image: volador/rapidocr:latest + container_name: picanalysis-rapidocr + restart: unless-stopped + ports: + - "13058:9004" + networks: + - picanalysis-network ``` -然后更新 `.env` 文件: +后端环境变量自动配置: +```bash +RAPIDOCR_API_URL="http://rapidocr:9004" +``` + +### 启动服务 ```bash -RAPIDOCR_API_URL="http://rapidocr:8080" -OCR_PROVIDER="rapidocr" +# 启动 RapidOCR 服务 +docker compose up -d rapidocr + +# 查看日志 +docker compose logs -f rapidocr + +# 测试服务 +curl http://localhost:13058 +# 应返回: {"message":"Welcome to RapidOCR Server!"} ``` -#### 方案 B: 使用外部 RapidOCR 服务 +### 使用方式 -如果你已经有运行中的 RapidOCR 服务,只需要配置 URL: - -```bash -# .env 文件 -RAPIDOCR_API_URL="http://your-rapidocr-host:8080" -OCR_PROVIDER="rapidocr" -``` - -#### 验证 RapidOCR - -```bash -# 测试 RapidOCR 服务是否可用 -curl http://localhost:8080 -``` +1. **自动选择**: 在设置页面选择 "RapidOCR" +2. **环境变量**: 设置 `OCR_PROVIDER=rapidocr` +3. **API 调用**: 后端自动使用 `http://rapidocr:9004` --- @@ -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 -- **Baidu OCR** - 云端调用,准确率高 -- **Tesseract.js** - 本地轻量级方案 +- **RapidOCR** ⭐ - 同样基于 PaddleOCR 引擎,但提供稳定的 HTTP API (已集成) +- **Baidu OCR** - 云端调用,准确率高,有免费额度 +- **Tesseract.js** - 本地轻量级方案,无需额外部署 + +### 如果需要使用 PaddleOCR +您可以: +1. 寻找其他维护良好的 PaddleOCR Docker 镜像 +2. 手动构建 PaddleOCR 服务(需要 Python 环境) +3. 使用官方 PaddleOCR 的其他部署方式 --- @@ -181,7 +194,8 @@ docker compose ps rapidocr docker compose logs rapidocr # 3. 测试 RapidOCR 连接 -curl http://localhost:8080 +curl http://localhost:13058 +# 应返回: {"message":"Welcome to RapidOCR Server!"} # 4. 如果服务未运行,启动它 docker compose up -d rapidocr diff --git a/backend/Dockerfile b/backend/Dockerfile index 96d455a..be4d005 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,7 +3,8 @@ # ======================================== 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 diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh index 1fbd41c..779407d 100644 --- a/backend/docker-entrypoint.sh +++ b/backend/docker-entrypoint.sh @@ -4,6 +4,11 @@ mkdir -p /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 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 if [ -f /app/data/prod.db ]; then - chown nodejs:nodejs /app/data/prod.db - chmod 664 /app/data/prod.db + chown nodejs:nodejs /app/data/prod.db + chmod 664 /app/data/prod.db 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 echo "Starting application..." exec su-exec nodejs npx tsx src/index.ts diff --git a/backend/src/controllers/image.controller.ts b/backend/src/controllers/image.controller.ts index 39142fd..a68cb39 100644 --- a/backend/src/controllers/image.controller.ts +++ b/backend/src/controllers/image.controller.ts @@ -20,6 +20,14 @@ export class ImageController { const file = req.file; 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) { res.status(400).json({ success: false, diff --git a/backend/src/index.ts b/backend/src/index.ts index dbefa1e..2115f51 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -29,16 +29,16 @@ app.use(express.urlencoded({ extended: true })); app.use('/uploads', express.static(path.join(process.cwd(), 'uploads'))); // Health check -app.get('/api/health', (_req, res) => { +app.get('/health', (_req, res) => { res.json({ success: true, message: 'API is running' }); }); // Routes -app.use('/api/auth', authRoutes); -app.use('/api/documents', documentRoutes); -app.use('/api/todos', todoRoutes); -app.use('/api/images', imageRoutes); -app.use('/api/user', userRoutes); +app.use('/auth', authRoutes); +app.use('/documents', documentRoutes); +app.use('/todos', todoRoutes); +app.use('/images', imageRoutes); +app.use('/user', userRoutes); // 404 handler app.use((_req, res) => { diff --git a/backend/src/routes/image.routes.ts b/backend/src/routes/image.routes.ts index 05ac52c..7780bef 100644 --- a/backend/src/routes/image.routes.ts +++ b/backend/src/routes/image.routes.ts @@ -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 * @desc Get image by ID diff --git a/backend/src/services/ocr-providers/index.ts b/backend/src/services/ocr-providers/index.ts index 20083cc..2a7a07d 100644 --- a/backend/src/services/ocr-providers/index.ts +++ b/backend/src/services/ocr-providers/index.ts @@ -8,15 +8,17 @@ export type { IImageSource, OCRRecognitionResult, OCRProviderConfig } from './ba export { TesseractProvider, tesseractProvider } from './tesseract.provider'; export { BaiduProvider, baiduProvider } from './baidu.provider'; export { RapidOCRProvider, rapidocrProvider } from './rapidocr.provider'; +export { PaddleOCRProvider, paddleocrProvider } from './paddleocr.provider'; import { TesseractProvider } from './tesseract.provider'; import { BaiduProvider } from './baidu.provider'; import { RapidOCRProvider } from './rapidocr.provider'; +import { PaddleOCRProvider } from './paddleocr.provider'; /** * OCR Provider 类型 */ -export type OCRProviderType = 'tesseract' | 'baidu' | 'rapidocr' | 'auto'; +export type OCRProviderType = 'tesseract' | 'baidu' | 'rapidocr' | 'paddleocr' | 'auto'; /** * OCR Provider 工厂 @@ -27,6 +29,7 @@ export class OCRProviderFactory { tesseract: TesseractProvider, baidu: BaiduProvider, rapidocr: RapidOCRProvider, + paddleocr: PaddleOCRProvider, }; /** @@ -35,7 +38,7 @@ export class OCRProviderFactory { static create( type: OCRProviderType, config?: any - ): TesseractProvider | BaiduProvider | RapidOCRProvider { + ): TesseractProvider | BaiduProvider | RapidOCRProvider | PaddleOCRProvider { if (type === 'auto') { // 自动选择可用的 provider return this.autoSelect(); @@ -51,9 +54,9 @@ export class OCRProviderFactory { /** * 自动选择可用的 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; // 如果指定了 provider 且不是 auto,使用指定的 @@ -63,6 +66,11 @@ export class OCRProviderFactory { } // 检查可用性并选择 + // PaddleOCR (本地高精度) + if (process.env.PADDLEOCR_API_URL) { + return new PaddleOCRProvider(); + } + // RapidOCR (本地快速) if (process.env.RAPIDOCR_API_URL) { return new RapidOCRProvider(); @@ -84,6 +92,7 @@ export class OCRProviderFactory { Array<{ type: string; name: string; available: boolean; typeDesc: string }> > { const providers = [ + { type: 'paddleocr', name: 'PaddleOCR', instance: new PaddleOCRProvider(), typeDesc: '本地高精度' }, { type: 'rapidocr', name: 'RapidOCR', instance: new RapidOCRProvider(), typeDesc: '本地快速准确' }, { type: 'baidu', name: 'Baidu OCR', instance: new BaiduProvider(), typeDesc: '云端准确' }, { type: 'tesseract', name: 'Tesseract.js', instance: new TesseractProvider(), typeDesc: '本地轻量' }, diff --git a/backend/src/services/ocr-providers/paddleocr.provider.ts b/backend/src/services/ocr-providers/paddleocr.provider.ts new file mode 100644 index 0000000..344fe76 --- /dev/null +++ b/backend/src/services/ocr-providers/paddleocr.provider.ts @@ -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>; + 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 { + 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 { + 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 { + 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(promise: Promise, timeout: number): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error('timeout')), timeout) + ), + ]); + } +} + +// 导出单例实例 +export const paddleocrProvider = new PaddleOCRProvider(); diff --git a/backend/src/services/ocr-providers/rapidocr.provider.ts b/backend/src/services/ocr-providers/rapidocr.provider.ts index 2af4422..26325cc 100644 --- a/backend/src/services/ocr-providers/rapidocr.provider.ts +++ b/backend/src/services/ocr-providers/rapidocr.provider.ts @@ -54,9 +54,10 @@ export class RapidOCRProvider extends BaseOCRProvider { */ async isAvailable(): Promise { try { - const response = await fetch(`${this.apiUrl}/health`, { + const response = await fetch(`${this.apiUrl}/`, { signal: AbortSignal.timeout(2000), }); + // RapidOCR 返回 {"message":"Welcome to RapidOCR Server!"} return response.ok; } catch { return false; diff --git a/docker-compose.yml b/docker-compose.yml index 5672310..ce518d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,12 +26,18 @@ services: DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} UPLOAD_MAX_SIZE: ${UPLOAD_MAX_SIZE:-10485760} 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: # Persist database and uploads - backend-data:/app/data - backend-uploads:/app/uploads networks: - picanalysis-network + depends_on: + - rapidocr + - paddleocr healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:13057/api/health"] interval: 30s @@ -39,6 +45,45 @@ services: retries: 3 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 # ======================== diff --git a/frontend/nginx.docker.conf b/frontend/nginx.docker.conf index 5d76f17..5fa1c6f 100644 --- a/frontend/nginx.docker.conf +++ b/frontend/nginx.docker.conf @@ -16,21 +16,34 @@ server { add_header X-XSS-Protection "1; mode=block" always; # API proxy to backend - location /api { - proxy_pass http://backend:13057; + location /api/ { + proxy_pass http://backend:13057/; 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 X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 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_no_cache $http_upgrade; } # Upload files proxy to backend - location /uploads { - proxy_pass http://backend:13057/uploads; + 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; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 232df41..d771324 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,7 +22,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", - "@playwright/test": "^1.58.2", + "@playwright/test": "^1.40.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -1328,19 +1328,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz", + "integrity": "sha512-PdW+kn4eV99iP5gxWNSDQCbhMaDVej+RXL5xr6t04nbKLCBwYtA046t7ofoczHOm8u6c+45hpDKQVZqtqwkeQg==", + "deprecated": "Please update to the latest version of Playwright to test up-to-date browsers.", "dev": true, - "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.2" + "playwright": "1.40.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=16" } }, "node_modules/@polka/url": { @@ -5200,35 +5200,33 @@ } }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz", + "integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.40.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=16" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz", + "integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==", "dev": true, - "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=16" } }, "node_modules/playwright/node_modules/fsevents": { @@ -5237,7 +5235,6 @@ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, - "license": "MIT", "optional": true, "os": [ "darwin" diff --git a/frontend/package.json b/frontend/package.json index 2c6f549..fa980e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,7 +29,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", - "@playwright/test": "^1.58.2", + "@playwright/test": "^1.40.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 6ce81d8..89dc11f 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -6,7 +6,7 @@ import { Settings, Save, CheckCircle, XCircle, Eye, EyeOff, Server, Globe, Datab // 从环境变量或 localStorage 获取 API 地址 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 = { @@ -14,13 +14,14 @@ type ApiConfig = { }; type OCRConfig = { - provider: 'auto' | 'tesseract' | 'baidu' | 'tencent' | 'rapidocr'; + provider: 'auto' | 'tesseract' | 'baidu' | 'tencent' | 'rapidocr' | 'paddleocr'; confidenceThreshold: number; baiduApiKey: string; baiduSecretKey: string; tencentSecretId: string; tencentSecretKey: string; rapidocrUrl: string; + paddleocrUrl: string; }; type AIConfig = { @@ -62,7 +63,8 @@ const defaultOCRConfig: OCRConfig = { baiduSecretKey: '', tencentSecretId: '', tencentSecretKey: '', - rapidocrUrl: 'http://localhost:8080', + rapidocrUrl: 'http://localhost:13058', + paddleocrUrl: 'http://localhost:13059', }; const defaultAIConfig: AIConfig = { @@ -504,6 +506,7 @@ export default function SettingsPage() { + @@ -640,6 +643,45 @@ export default function SettingsPage() { )} + + +
+
+ + updateOcrConfig('paddleocrUrl', e.target.value)} + placeholder="http://localhost:8866" + /> +
+
+
+

+ 特点: 免费、离线、高精度、多语言支持 (百度开源) +

+

+ 启动: docker run -p 8866:8866 987846/paddleocr:latest +

+
+ +
+ {testResults.paddleocr && ( +
+ {testResults.paddleocr.success ? : } + {testResults.paddleocr.message} +
+ )} +
+
)} diff --git a/paddleocr/Dockerfile b/paddleocr/Dockerfile new file mode 100644 index 0000000..00dec0d --- /dev/null +++ b/paddleocr/Dockerfile @@ -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"] diff --git a/paddleocr/paddleocr_api.py b/paddleocr/paddleocr_api.py new file mode 100644 index 0000000..8fbc320 --- /dev/null +++ b/paddleocr/paddleocr_api.py @@ -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) diff --git a/paddleocr/requirements.txt b/paddleocr/requirements.txt new file mode 100644 index 0000000..30fa5a7 --- /dev/null +++ b/paddleocr/requirements.txt @@ -0,0 +1,5 @@ +paddleocr==2.7.0 +protobuf>=3.20.2 +flask==2.3.0 +pillow==10.0.0 +numpy<2.0.0