From 83dfe189771bf2552500a2559daf98bdb4c27eb7 Mon Sep 17 00:00:00 2001 From: pgmsoul Date: Mon, 22 Jun 2026 08:31:19 +0800 Subject: [PATCH] init --- .dockerignore | 13 +++ .env.example | 8 ++ .gitignore | 61 ++++++++++++ Dockerfile | 21 +++++ README.md | 97 +++++++++++++++++++ docker-compose.yml | 11 +++ pyproject.toml | 41 ++++++++ src/openocr_markdown/__init__.py | 3 + src/openocr_markdown/api.py | 156 +++++++++++++++++++++++++++++++ src/openocr_markdown/cli.py | 28 ++++++ src/openocr_markdown/config.py | 33 +++++++ src/openocr_markdown/engine.py | 31 ++++++ src/openocr_markdown/schemas.py | 22 +++++ tests/test_api_utils.py | 9 ++ 14 files changed, 534 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 src/openocr_markdown/__init__.py create mode 100644 src/openocr_markdown/api.py create mode 100644 src/openocr_markdown/cli.py create mode 100644 src/openocr_markdown/config.py create mode 100644 src/openocr_markdown/engine.py create mode 100644 src/openocr_markdown/schemas.py create mode 100644 tests/test_api_utils.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c85e426 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.venv/ +venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.env +data/ +models/ +openocr_output/ +dist/ +build/ +.git/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a5cae9c --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +OPENOCR_OUTPUT_DIR=data/output +OPENOCR_UPLOAD_DIR=data/uploads +OPENOCR_USE_GPU=false +OPENOCR_AUTO_DOWNLOAD=true +OPENOCR_LAYOUT_THRESHOLD=0.4 +OPENOCR_MAX_LENGTH=2048 +OPENOCR_CORS_ORIGINS=* +OPENOCR_LOG_LEVEL=info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..60fde5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg +*.egg-info/ +.eggs/ +dist/ +build/ +*.manifest +*.spec + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# Test / lint / type check +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# Environment and secrets +.env +.env.* +!.env.example + +# Runtime data and model cache +data/ +models/ +openocr_output/ +.cache/ +.huggingface/ +.modelscope/ + +# Large model weights (downloaded at runtime) +*.onnx +*.pth +*.pt +*.bin +*.safetensors +*.whl + +# Logs +*.log +logs/ + +# IDE / OS +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store +Thumbs.db +desktop.ini diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..126ffc5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.10-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml README.md ./ +COPY src ./src + +RUN pip install --upgrade pip \ + && pip install . + +EXPOSE 8000 + +CMD ["uvicorn", "openocr_markdown.api:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..336c996 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# OpenOCR Markdown Service + +一个精简版图片/PDF 转 Markdown 服务,核心依赖 `openocr-python==0.1.5`,保留文档解析能力,去掉训练、评测和实验脚本。 + +## 功能 + +- `POST /api/recognize`:上传图片或 PDF,生成 Markdown 和 JSON。 +- `GET /api/history`:查看已解析文件列表。 +- `GET /output/...`:访问生成的 Markdown、JSON 和原始文件。 +- `openocr-md`:命令行直接解析单个图片/PDF。 + +## 本地运行 + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e . +cp .env.example .env +uvicorn openocr_markdown.api:app --host 0.0.0.0 --port 8000 +``` + +Windows PowerShell: + +```powershell +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -e . +Copy-Item .env.example .env +uvicorn openocr_markdown.api:app --host 0.0.0.0 --port 8000 +``` + +首次运行会自动下载 OpenOCR 所需模型,耗时取决于网络和机器性能。 + +## API 示例 + +```bash +curl -F "file=@examples/page.png" http://127.0.0.1:8000/api/recognize +curl http://127.0.0.1:8000/api/history +``` + +重复上传同名文件默认复用已有结果;需要重新识别时增加 `force=true`: + +```bash +curl -F "file=@examples/page.png" -F "force=true" http://127.0.0.1:8000/api/recognize +``` + +## 命令行 + +```bash +openocr-md path/to/document.pdf --output-dir data/output +``` + +## Docker 部署 + +```bash +cp .env.example .env +docker compose up -d --build +``` + +服务启动后访问: + +- API 文档:`http://服务器IP:8000/docs` +- 健康检查:`http://服务器IP:8000/health` +- 输出文件:`http://服务器IP:8000/output/...` + +## 关键配置 + +通过 `.env` 或服务器环境变量配置: + +```env +OPENOCR_OUTPUT_DIR=data/output +OPENOCR_UPLOAD_DIR=data/uploads +OPENOCR_USE_GPU=false +OPENOCR_AUTO_DOWNLOAD=true +OPENOCR_LAYOUT_THRESHOLD=0.4 +OPENOCR_MAX_LENGTH=2048 +OPENOCR_CORS_ORIGINS=* +``` + +如果远程服务器有 GPU,可以先安装带 GPU 的运行环境,再把 `OPENOCR_USE_GPU=true`。当前 Dockerfile 默认是 CPU 版,适合先验证完整链路。 + +## 项目结构 + +```text +. +├── src/openocr_markdown/ +│ ├── api.py # FastAPI 服务 +│ ├── cli.py # 命令行入口 +│ ├── config.py # 环境变量配置 +│ ├── engine.py # OpenOCR 模型加载和解析封装 +│ └── schemas.py # API 响应模型 +├── tests/ +├── Dockerfile +├── docker-compose.yml +├── pyproject.toml +└── .env.example +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..640b5df --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + openocr-markdown: + build: . + container_name: openocr-markdown + restart: unless-stopped + ports: + - "8000:8000" + env_file: + - .env + volumes: + - ./data:/app/data diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..940ea43 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "openocr-markdown-service" +version = "0.1.0" +description = "FastAPI service that converts images or PDFs to Markdown with OpenOCR." +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "python-multipart>=0.0.9", + "pydantic-settings>=2.4", + "openocr-python==0.1.5", +] + +[project.optional-dependencies] +server = [ + "torch", + "torchvision", +] +dev = [ + "pytest>=8.0", + "ruff>=0.6", +] + +[project.scripts] +openocr-md-api = "openocr_markdown.api:main" +openocr-md = "openocr_markdown.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/openocr_markdown/__init__.py b/src/openocr_markdown/__init__.py new file mode 100644 index 0000000..535c42a --- /dev/null +++ b/src/openocr_markdown/__init__.py @@ -0,0 +1,3 @@ +"""OpenOCR Markdown service package.""" + +__version__ = "0.1.0" diff --git a/src/openocr_markdown/api.py b/src/openocr_markdown/api.py new file mode 100644 index 0000000..54bc688 --- /dev/null +++ b/src/openocr_markdown/api.py @@ -0,0 +1,156 @@ +import re +import shutil +import time +from pathlib import Path +from uuid import uuid4 + +import uvicorn +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse +from fastapi.staticfiles import StaticFiles + +from .config import settings +from .engine import get_doc_parser, parse_to_outputs +from .schemas import HistoryItem, HistoryResponse, RecognizeResponse + + +SUPPORTED_SUFFIXES = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".pdf"} + + +def safe_stem(filename: str) -> str: + stem = Path(filename).stem.strip() + stem = re.sub(r"[^a-zA-Z0-9._-]+", "_", stem) + return stem.strip("._-") or f"document_{uuid4().hex[:8]}" + + +def find_first(path: Path, patterns: tuple[str, ...]) -> Path | None: + for pattern in patterns: + match = next(path.glob(pattern), None) + if match: + return match + return None + + +def create_app() -> FastAPI: + settings.output_dir.mkdir(parents=True, exist_ok=True) + settings.upload_dir.mkdir(parents=True, exist_ok=True) + + app = FastAPI(title=settings.app_name) + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_credentials=settings.cors_origin_list != ["*"], + allow_methods=["*"], + allow_headers=["*"], + ) + + @app.on_event("startup") + async def warm_up_model() -> None: + get_doc_parser() + + @app.get("/", include_in_schema=False) + async def root() -> RedirectResponse: + return RedirectResponse(url="/docs") + + @app.get("/health") + async def health() -> dict[str, str]: + return {"status": "ok"} + + @app.get("/api/history", response_model=HistoryResponse) + async def history() -> HistoryResponse: + items: list[HistoryItem] = [] + for item_dir in settings.output_dir.iterdir(): + if not item_dir.is_dir(): + continue + markdown_file = find_first(item_dir, ("*.md",)) + if not markdown_file: + continue + json_file = find_first(item_dir, ("*.json",)) + source_file = find_first(item_dir, ("origin_*",)) + items.append( + HistoryItem( + id=item_dir.name, + markdown_url=f"/output/{item_dir.name}/{markdown_file.name}", + json_url=f"/output/{item_dir.name}/{json_file.name}" if json_file else None, + source_url=f"/output/{item_dir.name}/{source_file.name}" if source_file else None, + updated_at=markdown_file.stat().st_mtime, + ) + ) + items.sort(key=lambda item: item.updated_at, reverse=True) + return HistoryResponse(data=items) + + @app.post("/api/recognize", response_model=RecognizeResponse) + async def recognize( + file: UploadFile = File(...), + force: bool = Form(default=False), + ) -> RecognizeResponse: + if not file.filename: + raise HTTPException(status_code=400, detail="Missing filename.") + + suffix = Path(file.filename).suffix.lower() + if suffix not in SUPPORTED_SUFFIXES: + raise HTTPException(status_code=400, detail=f"Unsupported file type: {suffix}") + + doc_id = safe_stem(file.filename) + output_dir = settings.output_dir / doc_id + markdown_file = output_dir / f"{doc_id}.md" + + if output_dir.exists(): + if force: + shutil.rmtree(output_dir) + elif markdown_file.exists(): + return RecognizeResponse( + id=doc_id, + status="success", + message="Existing result reused. Set force=true to re-run.", + markdown_url=f"/output/{doc_id}/{markdown_file.name}", + json_url=f"/output/{doc_id}/{doc_id}.json", + ) + + output_dir.mkdir(parents=True, exist_ok=True) + upload_path = settings.upload_dir / f"{doc_id}{suffix}" + source_path = output_dir / f"origin_{Path(file.filename).name}" + + with upload_path.open("wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + started_at = time.time() + try: + parse_to_outputs(upload_path, settings.output_dir) + if upload_path.exists(): + shutil.move(str(upload_path), source_path) + except Exception as exc: + if upload_path.exists(): + upload_path.unlink() + raise HTTPException(status_code=500, detail=str(exc)) from exc + + elapsed = round(time.time() - started_at, 2) + return RecognizeResponse( + id=doc_id, + status="success", + message=f"Parsed successfully in {elapsed}s.", + markdown_url=f"/output/{doc_id}/{doc_id}.md", + json_url=f"/output/{doc_id}/{doc_id}.json", + source_url=f"/output/{doc_id}/{source_path.name}", + ) + + app.mount("/output", StaticFiles(directory=settings.output_dir), name="output") + return app + + +app = create_app() + + +def main() -> None: + uvicorn.run( + "openocr_markdown.api:app", + host="0.0.0.0", + port=8000, + reload=False, + log_level=settings.log_level, + ) + + +if __name__ == "__main__": + main() diff --git a/src/openocr_markdown/cli.py b/src/openocr_markdown/cli.py new file mode 100644 index 0000000..9647674 --- /dev/null +++ b/src/openocr_markdown/cli.py @@ -0,0 +1,28 @@ +import argparse +from pathlib import Path + +from .config import settings +from .engine import parse_to_outputs + + +def main() -> None: + parser = argparse.ArgumentParser(description="Convert an image or PDF to Markdown with OpenOCR.") + parser.add_argument("input_path", type=Path, help="Image or PDF path.") + parser.add_argument( + "--output-dir", + type=Path, + default=settings.output_dir, + help="Directory where OpenOCR outputs are written.", + ) + args = parser.parse_args() + + if not args.input_path.exists(): + raise SystemExit(f"Input file does not exist: {args.input_path}") + + args.output_dir.mkdir(parents=True, exist_ok=True) + parse_to_outputs(args.input_path, args.output_dir) + print(f"Done. Results written under: {args.output_dir}") + + +if __name__ == "__main__": + main() diff --git a/src/openocr_markdown/config.py b/src/openocr_markdown/config.py new file mode 100644 index 0000000..2fd66e7 --- /dev/null +++ b/src/openocr_markdown/config.py @@ -0,0 +1,33 @@ +from pathlib import Path +from typing import Literal + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Runtime settings loaded from environment variables.""" + + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + app_name: str = "OpenOCR Markdown Service" + output_dir: Path = Field(default=Path("data/output"), alias="OPENOCR_OUTPUT_DIR") + upload_dir: Path = Field(default=Path("data/uploads"), alias="OPENOCR_UPLOAD_DIR") + use_gpu: bool = Field(default=False, alias="OPENOCR_USE_GPU") + auto_download: bool = Field(default=True, alias="OPENOCR_AUTO_DOWNLOAD") + layout_threshold: float = Field(default=0.4, alias="OPENOCR_LAYOUT_THRESHOLD") + max_length: int = Field(default=2048, alias="OPENOCR_MAX_LENGTH") + cors_origins: str = Field(default="*", alias="OPENOCR_CORS_ORIGINS") + log_level: Literal["debug", "info", "warning", "error"] = Field( + default="info", + alias="OPENOCR_LOG_LEVEL", + ) + + @property + def cors_origin_list(self) -> list[str]: + if self.cors_origins.strip() == "*": + return ["*"] + return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] + + +settings = Settings() diff --git a/src/openocr_markdown/engine.py b/src/openocr_markdown/engine.py new file mode 100644 index 0000000..4264a60 --- /dev/null +++ b/src/openocr_markdown/engine.py @@ -0,0 +1,31 @@ +from functools import lru_cache +from pathlib import Path +from typing import Any + +from openocr import OpenOCR + +from .config import settings + + +@lru_cache(maxsize=1) +def get_doc_parser() -> OpenOCR: + """Load the document parser once per worker process.""" + + return OpenOCR( + task="doc", + use_gpu=settings.use_gpu, + auto_download=settings.auto_download, + use_layout_detection=True, + ) + + +def parse_to_outputs(input_path: Path, output_dir: Path) -> dict[str, Any]: + parser = get_doc_parser() + result = parser( + image_path=str(input_path), + layout_threshold=settings.layout_threshold, + max_length=settings.max_length, + ) + parser.save_to_markdown(result, str(output_dir)) + parser.save_to_json(result, str(output_dir)) + return result diff --git a/src/openocr_markdown/schemas.py b/src/openocr_markdown/schemas.py new file mode 100644 index 0000000..f3566e0 --- /dev/null +++ b/src/openocr_markdown/schemas.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel + + +class RecognizeResponse(BaseModel): + id: str + status: str + message: str + markdown_url: str | None = None + json_url: str | None = None + source_url: str | None = None + + +class HistoryItem(BaseModel): + id: str + markdown_url: str + json_url: str | None = None + source_url: str | None = None + updated_at: float + + +class HistoryResponse(BaseModel): + data: list[HistoryItem] diff --git a/tests/test_api_utils.py b/tests/test_api_utils.py new file mode 100644 index 0000000..5e7181b --- /dev/null +++ b/tests/test_api_utils.py @@ -0,0 +1,9 @@ +from openocr_markdown.api import safe_stem + + +def test_safe_stem_keeps_simple_names() -> None: + assert safe_stem("invoice-01.png") == "invoice-01" + + +def test_safe_stem_replaces_unsafe_names() -> None: + assert safe_stem("../report 01!.pdf") == "report_01"