chore: import upstream snapshot with attribution
Build/Publish Develop Docs / deploy (push) Failing after 1s
PaddleOCR Code Style Check / check-code-style (push) Failing after 1s
PaddleOCR PR Tests GPU / detect-changes (push) Failing after 1s
PaddleOCR PR Tests / detect-changes (push) Failing after 1s
PaddleOCR PR Tests GPU / test-pr-gpu (push) Has been cancelled
PaddleOCR PR Tests / test-pr (push) Has been cancelled
PaddleOCR PR Tests GPU / test-pr-gpu-impl (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.13) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.8) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.9) (push) Has been cancelled
Build/Publish Develop Docs / deploy (push) Failing after 1s
PaddleOCR Code Style Check / check-code-style (push) Failing after 1s
PaddleOCR PR Tests GPU / detect-changes (push) Failing after 1s
PaddleOCR PR Tests / detect-changes (push) Failing after 1s
PaddleOCR PR Tests GPU / test-pr-gpu (push) Has been cancelled
PaddleOCR PR Tests / test-pr (push) Has been cancelled
PaddleOCR PR Tests GPU / test-pr-gpu-impl (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.13) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.8) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.9) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
# PaddleOCR
|
||||
|
||||
name: 🐛 Bug Report
|
||||
description: Problems with PaddleOCR
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for submitting a PaddleOCR 🐛 Bug Report!
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: 🔎 Search before asking
|
||||
description: >
|
||||
Please search the PaddleOCR [Docs](https://paddlepaddle.github.io/PaddleOCR/), [Issues](https://github.com/PaddlePaddle/PaddleOCR/issues) and [Discussions](https://github.com/PaddlePaddle/PaddleOCR/discussions) to see if a similar bug report already exists.
|
||||
options:
|
||||
- label: I have searched the PaddleOCR [Docs](https://paddlepaddle.github.io/PaddleOCR/) and found no similar bug report.
|
||||
required: true
|
||||
- label: I have searched the PaddleOCR [Issues](https://github.com/PaddlePaddle/PaddleOCR/issues) and found no similar bug report.
|
||||
required: true
|
||||
- label: I have searched the PaddleOCR [Discussions](https://github.com/PaddlePaddle/PaddleOCR/discussions) and found no similar bug report.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 🐛 Bug (问题描述)
|
||||
description: Provide console output with error messages and/or screenshots of the bug. (请提供详细报错信息或者截图)
|
||||
placeholder: |
|
||||
💡 ProTip! Include as much information as possible (screenshots, logs, tracebacks etc.) to receive the most helpful response.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 🏃♂️ Environment (运行环境)
|
||||
description: Please specify the software and hardware you used to produce the bug. (请给出详细依赖包信息,便于复现问题)
|
||||
placeholder: |
|
||||
```bash
|
||||
OS macOS-13.5.2
|
||||
Environment Jupyter
|
||||
Python 3.11.2
|
||||
PaddleOCR 2.8.1
|
||||
Install git
|
||||
RAM 16.00 GB
|
||||
CPU Apple M2
|
||||
CUDA None
|
||||
```
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 🌰 Minimal Reproducible Example (最小可复现问题的Demo)
|
||||
description: >
|
||||
When asking a question, people will be better able to provide help if you provide code that they can easily understand and use to **reproduce** the problem.
|
||||
This is referred to by community members as creating a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example). (请务必提供该Demo,这样节省大家时间)
|
||||
placeholder: |
|
||||
```bash
|
||||
# Code to reproduce your issue here
|
||||
```
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 🙏 Q&A
|
||||
url: https://github.com/PaddlePaddle/PaddleOCR/discussions/categories/q-a
|
||||
about: Ask the community for help
|
||||
- name: 💡 Feature requests and ideas
|
||||
url: https://github.com/PaddlePaddle/PaddleOCR/discussions/categories/ideas
|
||||
about: Share ideas for new features
|
||||
- name: 🙌 Show and tell
|
||||
url: https://github.com/PaddlePaddle/PaddleOCR/discussions/categories/show-and-tell
|
||||
about: Show off something you've made
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Detect Docs-Only Change
|
||||
description: >
|
||||
Output docs_only=true if every changed file in the current pull_request
|
||||
matches docs/**, **/*.md, or .github/**. On push or workflow_dispatch,
|
||||
always output docs_only=false.
|
||||
outputs:
|
||||
docs_only:
|
||||
description: "true if change is docs-only, otherwise false"
|
||||
value: ${{ steps.compute.outputs.docs_only }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: compute
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${GITHUB_EVENT_NAME}" != "pull_request" ]; then
|
||||
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
git fetch origin "${GITHUB_BASE_REF}" --depth=1 >/dev/null 2>&1 || true
|
||||
CHANGED_FILES="$(git diff --name-only "origin/${GITHUB_BASE_REF}...HEAD")"
|
||||
# >>> matcher-begin
|
||||
shopt -s globstar extglob nullglob
|
||||
is_docs_only() {
|
||||
local files="$1"
|
||||
if [ -z "$files" ]; then
|
||||
echo "false"; return 0
|
||||
fi
|
||||
while IFS= read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
case "$f" in
|
||||
docs/*) ;;
|
||||
.github/*) ;;
|
||||
*.md) ;;
|
||||
*)
|
||||
# also accept nested */**/*.md via case glob
|
||||
case "$f" in
|
||||
*/*.md) ;;
|
||||
*) echo "false"; return 0 ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
done <<< "$files"
|
||||
echo "true"
|
||||
}
|
||||
result="$(is_docs_only "${CHANGED_FILES}")"
|
||||
if [ -n "${GITHUB_OUTPUT:-}" ] && [ "${GITHUB_OUTPUT}" != "/dev/null" ]; then
|
||||
echo "docs_only=${result}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "${result}"
|
||||
# <<< matcher-end
|
||||
@@ -0,0 +1,13 @@
|
||||
# Keep GitHub Actions up to date with GitHub's Dependabot...
|
||||
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
|
||||
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*" # Group all Actions updates into a single larger pull request
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Build/Publish Develop Docs
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Configure Git Credentials
|
||||
run: |
|
||||
git config user.name github-actions[bot]
|
||||
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.x
|
||||
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
key: mkdocs-material-${{ env.cache_id }}
|
||||
path: .cache
|
||||
restore-keys: |
|
||||
mkdocs-material-
|
||||
- run: pip install mike mkdocs-material jieba mkdocs-git-revision-date-localized-plugin mkdocs-git-committers-plugin-2 mkdocs-static-i18n markdown-callouts
|
||||
- name: Check docs GitHub source links
|
||||
run: python tools/check_docs_github_links.py --repo-slug PaddlePaddle/PaddleOCR --forbidden-ref main --forbidden-ref master
|
||||
- name: Resolve docs GitHub source refs
|
||||
run: python tools/resolve_doc_github_refs.py --placeholder '{{PADDLEOCR_GITHUB_REF}}' --source-ref "${REF_NAME}"
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
- run: |
|
||||
git fetch origin gh-pages --depth=1
|
||||
mike deploy --push --update-aliases main latest
|
||||
env:
|
||||
DOCS_EDIT_URI: edit/${{ github.ref_name }}/docs/
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Build/Publish Release Docs
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Configure Git Credentials
|
||||
run: |
|
||||
git config user.name github-actions[bot]
|
||||
git config user.email github-actions[bot]@users.noreply.github.com
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.x
|
||||
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
key: mkdocs-material-${{ env.cache_id }}
|
||||
path: .cache
|
||||
restore-keys: |
|
||||
mkdocs-material-
|
||||
- run: pip install mike mkdocs-material jieba mkdocs-git-revision-date-localized-plugin mkdocs-git-committers-plugin-2 mkdocs-static-i18n markdown-callouts
|
||||
- name: Check docs GitHub source links
|
||||
run: python tools/check_docs_github_links.py --repo-slug PaddlePaddle/PaddleOCR --forbidden-ref main --forbidden-ref master
|
||||
- name: Resolve docs GitHub source refs
|
||||
run: python tools/resolve_doc_github_refs.py --placeholder '{{PADDLEOCR_GITHUB_REF}}' --source-ref "${REF_NAME}"
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
- run: |
|
||||
git fetch origin gh-pages --depth=1
|
||||
mike deploy --push "${REF_NAME}"
|
||||
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
DOCS_EDIT_URI: blob/${{ github.ref_name }}/docs/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Close inactive issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 14
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 90 days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: PaddleOCR Code Style Check
|
||||
|
||||
# NOTE: Job name `check-code-style` is the required status check context
|
||||
# configured in branch protection. Do not rename without updating settings.
|
||||
|
||||
on:
|
||||
pull_request: {}
|
||||
push:
|
||||
branches: ['main', 'release/*']
|
||||
|
||||
jobs:
|
||||
check-code-style:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
- id: detect
|
||||
uses: ./.github/actions/detect-docs-only
|
||||
- uses: actions/setup-python@v6
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Cache Python dependencies
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install Dependencies for Python
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install "clang-format==13.0.0"
|
||||
- uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
with:
|
||||
extra_args: '--all-files'
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Docs Anchor Link Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- 'mkdocs-ci.yml'
|
||||
- 'overrides/**'
|
||||
|
||||
jobs:
|
||||
check-anchor-links:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-mkdocs-${{ hashFiles('mkdocs.yml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-mkdocs-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install mike mkdocs-material jieba mkdocs-git-revision-date-localized-plugin mkdocs-git-committers-plugin-2 mkdocs-static-i18n markdown-callouts
|
||||
|
||||
- name: Resolve docs GitHub source refs
|
||||
run: python tools/resolve_doc_github_refs.py --placeholder '{{PADDLEOCR_GITHUB_REF}}' --source-ref main
|
||||
|
||||
- name: Check for broken anchor links
|
||||
env:
|
||||
ENABLE_GIT_PLUGINS: 'false'
|
||||
run: mkdocs build -f mkdocs-ci.yml
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Link Checker
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
workflow_dispatch:
|
||||
# push:
|
||||
# branches:
|
||||
# - main
|
||||
# schedule:
|
||||
# - cron: "00 18 * * 6"
|
||||
|
||||
jobs:
|
||||
linkChecker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Resolve docs GitHub source refs
|
||||
run: python tools/resolve_doc_github_refs.py --placeholder '{{PADDLEOCR_GITHUB_REF}}' --source-ref main
|
||||
|
||||
- name: Link Checker
|
||||
id: lychee
|
||||
uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
with:
|
||||
args: --exclude 'docs/index/*.md' 'docs/update/*.md' --verbose --no-progress --max-redirects 8 'docs/**/*.md'
|
||||
format: markdown
|
||||
fail: false
|
||||
output: lychee/results.md
|
||||
|
||||
- name: Create Issue From File
|
||||
if: steps.lychee.outputs.exit_code != 0
|
||||
uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # v6
|
||||
with:
|
||||
title: Link Checker Report
|
||||
content-filepath: ./lychee/results.md
|
||||
labels: report, automated issue
|
||||
@@ -0,0 +1,39 @@
|
||||
# This workflow will upload a Python Package using Twine when a release is created
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
|
||||
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
name: Upload Python Package
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install build==1.2.2
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
- name: Publish package
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
@@ -0,0 +1,117 @@
|
||||
name: PaddleOCR PR Tests GPU
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
workflow_dispatch:
|
||||
env:
|
||||
PR_ID: ${{ github.event.pull_request.number }}
|
||||
COMMIT_ID: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
work_dir: /workspace/PaddleOCR
|
||||
PADDLENLP_ROOT: /workspace/PaddleOCR
|
||||
TASK: paddleocr-CI-${{ github.event.pull_request.number }}
|
||||
BRANCH: ${{ github.event.pull_request.base.ref }}
|
||||
AGILE_COMPILE_BRANCH: ${{ github.event.pull_request.base.ref }}
|
||||
DIR_NAME: ${{ github.repository }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
docs_only: ${{ steps.detect.outputs.docs_only }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: detect
|
||||
uses: ./.github/actions/detect-docs-only
|
||||
|
||||
test-pr-gpu-impl:
|
||||
runs-on: [self-hosted, GPU-2Card-OCR]
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.docs_only != 'true'
|
||||
steps:
|
||||
- name: run test
|
||||
env:
|
||||
py_version: "3.10"
|
||||
paddle_whl: https://paddle-qa.bj.bcebos.com/paddle-pipeline/Develop-GpuSome-LinuxCentos-Gcc82-Cuda118-Cudnn86-Trt85-Py310-CINN-Compile/latest/paddlepaddle_gpu-0.0.0-cp310-cp310-linux_x86_64.whl
|
||||
docker_image: ccr-2vdh3abv-pub.cnc.bj.baidubce.com/paddlepaddle/paddle:latest-dev-cuda11.8-cudnn8.6-trt8.5-gcc82
|
||||
run: |
|
||||
work_dir=$RANDOM
|
||||
mkdir $work_dir
|
||||
cd $work_dir
|
||||
git clone --depth=1 https://github.com/PaddlePaddle/PaddleOCR.git -b main
|
||||
cd PaddleOCR
|
||||
if [ -n "${PR_ID}" ]; then
|
||||
git fetch origin pull/${PR_ID}/head:ci_build
|
||||
git checkout ci_build
|
||||
else
|
||||
git fetch --depth=1 origin "${COMMIT_ID}"
|
||||
git checkout "${COMMIT_ID}"
|
||||
fi
|
||||
docker run --gpus all --rm -i --name PaddleOCR_CI_$RANDOM \
|
||||
--shm-size=128g --net=host \
|
||||
-v $PWD:/workspace -w /workspace \
|
||||
-e "py_version=${py_version}" \
|
||||
-e "paddle_whl=${paddle_whl}" \
|
||||
${docker_image} /bin/bash -c '
|
||||
ldconfig;
|
||||
nvidia-smi
|
||||
df -hl
|
||||
|
||||
echo ${py_version}
|
||||
rm -rf run_env
|
||||
mkdir run_env
|
||||
ln -s $(which python${py_version}) run_env/python
|
||||
ln -s $(which python${py_version}) run_env/python3
|
||||
ln -s $(which pip${py_version}) run_env/pip
|
||||
export PATH=$PWD/run_env:${PATH}
|
||||
|
||||
git config --global --add safe.directory /workspace
|
||||
|
||||
python -m pip install paddlepaddle-gpu==3.1.0 -i https://www.paddlepaddle.org.cn/packages/stable/cu118/
|
||||
python -c "import paddle; paddle.version.show()"
|
||||
python -m pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
python -m pip install pytest
|
||||
if [ -f requirements.txt ]; then python -m pip install -r requirements.txt; fi
|
||||
PADDLEX_SERIES=$(grep -oE "paddlex\[[^]]+\]>=[0-9]+\.[0-9]+" pyproject.toml | head -1 | grep -oE "[0-9]+\.[0-9]+")
|
||||
if [ -z "$PADDLEX_SERIES" ]; then
|
||||
echo "Failed to determine PaddleX version requirement from pyproject.toml" >&2
|
||||
exit 1
|
||||
fi
|
||||
PADDLEX_BRANCH="release/${PADDLEX_SERIES}"
|
||||
echo "Installing PaddleX from branch: ${PADDLEX_BRANCH}"
|
||||
python -m pip install -e ".[all]" "paddlex@git+https://github.com/PaddlePaddle/PaddleX.git@${PADDLEX_BRANCH}"
|
||||
python -c "import paddlex; print(f\"Installed paddlex version: {paddlex.__version__}\")"
|
||||
python -m pytest --verbose tests/
|
||||
'
|
||||
|
||||
# Aggregator: produces a single check named `test-pr-gpu` so branch
|
||||
# protection (which requires the literal context `test-pr-gpu`) is satisfied
|
||||
# even when the heavy GPU job is skipped (e.g. docs-only changes). Without
|
||||
# this, a SKIPPED conclusion on the GPU job leaves the required check
|
||||
# unsatisfied and blocks merge.
|
||||
test-pr-gpu:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect-changes, test-pr-gpu-impl]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Verify required jobs
|
||||
run: |
|
||||
if [ "${{ needs.detect-changes.result }}" != "success" ]; then
|
||||
echo "detect-changes did not succeed: ${{ needs.detect-changes.result }}"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${{ needs.detect-changes.outputs.docs_only }}" = "true" ]; then
|
||||
echo "Docs-only change; treating GPU tests as not required."
|
||||
exit 0
|
||||
fi
|
||||
if [ "${{ needs.test-pr-gpu-impl.result }}" != "success" ]; then
|
||||
echo "test-pr-gpu-impl concluded with: ${{ needs.test-pr-gpu-impl.result }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "GPU tests passed."
|
||||
@@ -0,0 +1,108 @@
|
||||
name: PaddleOCR PR Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "release/*"]
|
||||
pull_request:
|
||||
branches: ["main", "release/*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
docs_only: ${{ steps.detect.outputs.docs_only }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: detect
|
||||
uses: ./.github/actions/detect-docs-only
|
||||
|
||||
test-pr-python:
|
||||
runs-on: ubuntu-latest
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.docs_only != 'true'
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.8", "3.9", "3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pip
|
||||
~/.local/lib/python${{ matrix.python-version }}/site-packages
|
||||
~/.paddleocr/
|
||||
key: ${{ runner.os }}-dependencies-${{ matrix.python-version }}-${{ hashFiles('**/requirements.txt', 'pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-dependencies-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pytest
|
||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||
# On py3.8 several paddlex transitive deps require py3.9+, so only
|
||||
# paddleocr[doc2md] is installable. See installation.md for the
|
||||
# supported extras-by-Python-version matrix.
|
||||
if [[ "${{ matrix.python-version }}" == "3.8" ]]; then
|
||||
python -m pip install paddlepaddle==3.0.0 -i https://www.paddlepaddle.org.cn/packages/stable/cpu/
|
||||
PADDLEOCR_EXTRAS="[doc2md]"
|
||||
else
|
||||
python -m pip install paddlepaddle==3.1.0 -i https://www.paddlepaddle.org.cn/packages/stable/cpu/
|
||||
PADDLEOCR_EXTRAS="[all]"
|
||||
fi
|
||||
PADDLEX_SERIES=$(grep -oE 'paddlex\[[^]]+\]>=[0-9]+\.[0-9]+' pyproject.toml | head -1 | grep -oE '[0-9]+\.[0-9]+')
|
||||
if [ -z "$PADDLEX_SERIES" ]; then
|
||||
echo "Failed to determine PaddleX version requirement from pyproject.toml" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python -m pip install "paddlex>=3.7.0,<3.8.0"
|
||||
PADDLEX_BRANCH="release/${PADDLEX_SERIES}"
|
||||
echo "Installing PaddleX from branch: ${PADDLEX_BRANCH}"
|
||||
python -m pip install -e ".${PADDLEOCR_EXTRAS}" "paddlex@git+https://github.com/PaddlePaddle/PaddleX.git@${PADDLEX_BRANCH}"
|
||||
python -c "import paddlex; print(f'Installed paddlex version: {paddlex.__version__}')"
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
# Skip py38_incompatible tests on py3.8.
|
||||
if [[ "${{ matrix.python-version }}" == "3.8" ]]; then
|
||||
pytest --verbose tests/ -m "not resource_intensive and not py38_incompatible"
|
||||
else
|
||||
pytest --verbose tests/
|
||||
fi
|
||||
|
||||
# Aggregator: produces a single check named `test-pr` so branch protection
|
||||
# (which requires the literal context `test-pr`) is satisfied. Without this,
|
||||
# the matrix above only emits `test-pr (3.x)` checks and the required
|
||||
# `test-pr` context never reports.
|
||||
test-pr:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect-changes, test-pr-python]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Verify required jobs
|
||||
run: |
|
||||
if [ "${{ needs.detect-changes.result }}" != "success" ]; then
|
||||
echo "detect-changes did not succeed: ${{ needs.detect-changes.result }}"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${{ needs.detect-changes.outputs.docs_only }}" = "true" ]; then
|
||||
echo "Docs-only change; skipping python tests."
|
||||
exit 0
|
||||
fi
|
||||
if [ "${{ needs.test-pr-python.result }}" != "success" ]; then
|
||||
echo "test-pr-python concluded with: ${{ needs.test-pr-python.result }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "All python matrix variants passed."
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
.ipynb_checkpoints/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
/inference/
|
||||
/inference_results/
|
||||
/output/
|
||||
/train_data/
|
||||
/log/
|
||||
|
||||
*.DS_Store
|
||||
*.vs
|
||||
*.user
|
||||
*~
|
||||
*.vscode
|
||||
*.idea
|
||||
|
||||
*.log
|
||||
.clang-format
|
||||
.clang_format.hook
|
||||
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
/deploy/android_demo/app/OpenCV/
|
||||
/deploy/android_demo/app/PaddleLite/
|
||||
/deploy/android_demo/app/.cxx/
|
||||
/deploy/android_demo/app/cache/
|
||||
|
||||
test_tipc/web/models/
|
||||
test_tipc/web/node_modules/
|
||||
|
||||
.venv/
|
||||
.worktrees/
|
||||
@@ -0,0 +1,5 @@
|
||||
zhuanlan.zhihu.com/*
|
||||
https://demo.doctrp.top/
|
||||
http://127.0.0.1:8001/
|
||||
http://localhost:9003
|
||||
https://rrc.cvc.uab.es/
|
||||
@@ -0,0 +1,46 @@
|
||||
exclude: ^(langchain-paddleocr/|paddleocr-js/)
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
args: ['--maxkb=512']
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: check-symlinks
|
||||
- id: detect-private-key
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
files: \.(c|cc|cxx|cpp|cu|h|hpp|hxx|py)$
|
||||
- repo: https://github.com/Lucas-C/pre-commit-hooks
|
||||
rev: v1.5.5
|
||||
hooks:
|
||||
- id: remove-crlf
|
||||
- id: remove-tabs
|
||||
files: \.(c|cc|cxx|cpp|cu|h|hpp|hxx|py)$
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: clang-format
|
||||
name: clang-format
|
||||
description: Format files with ClangFormat
|
||||
entry: bash .clang_format.hook -i
|
||||
language: system
|
||||
files: \.(c|cc|cxx|cpp|cu|h|hpp|hxx|cuh|proto)$
|
||||
# For Python files
|
||||
- repo: https://github.com/psf/black.git
|
||||
rev: 24.10.0
|
||||
hooks:
|
||||
- id: black
|
||||
files: (.*\.(py|pyi|bzl)|BUILD|.*\.BUILD|WORKSPACE)$
|
||||
|
||||
# Flake8
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 7.1.1
|
||||
hooks:
|
||||
- id: flake8
|
||||
args:
|
||||
- --count
|
||||
- --select=E9,F63,F7,F82,E721
|
||||
- --show-source
|
||||
- --statistics
|
||||
exclude: ^benchmark/|^test_tipc/
|
||||
@@ -0,0 +1,3 @@
|
||||
[style]
|
||||
based_on_style = pep8
|
||||
column_limit = 80
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright (c) 2016 PaddlePaddle Authors. All Rights Reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
prune .github
|
||||
prune applications
|
||||
prune benchmark
|
||||
prune configs
|
||||
prune deploy
|
||||
prune doc
|
||||
prune docs
|
||||
prune overrides
|
||||
prune ppocr/ext_op
|
||||
prune ppocr/losses
|
||||
prune ppocr/metrics
|
||||
prune ppocr/modeling
|
||||
prune ppocr/optimizer
|
||||
prune ppstructure/docs
|
||||
prune test_tipc
|
||||
prune tests
|
||||
exclude .clang_format.hook
|
||||
exclude .gitignore
|
||||
exclude .pre-commit-config.yaml
|
||||
exclude .style.yapf
|
||||
exclude mkdocs.yml
|
||||
exclude train.sh
|
||||
@@ -0,0 +1,316 @@
|
||||
|
||||
<div align="center">
|
||||
<p>
|
||||
<img width="800" src="https://raw.githubusercontent.com/cuicheng01/PaddleX_doc_images/main/images/paddleocr/README/Banner.png" alt="Star-history">
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
<h3>Global Leading OCR Toolkit & Document AI Engine</h3>
|
||||
|
||||
English | [简体中文](./readme/README_cn.md) | [繁體中文](./readme/README_tcn.md) | [日本語](./readme/README_ja.md) | [한국어](./readme/README_ko.md) | [Français](./readme/README_fr.md) | [Русский](./readme/README_ru.md) | [Español](./readme/README_es.md) | [العربية](./readme/README_ar.md)
|
||||
|
||||
<!-- icon -->
|
||||
|
||||
[](https://pepy.tech/projects/paddleocr)
|
||||
[](https://github.com/PaddlePaddle/PaddleOCR/network/dependents)
|
||||

|
||||

|
||||

|
||||
|
||||
[](https://www.paddleocr.com)
|
||||
[](https://deepwiki.com/PaddlePaddle/PaddleOCR)
|
||||
[](../LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
**PaddleOCR converts PDF documents and images into structured, LLM-ready data (JSON/Markdown) with industry-leading accuracy. With 70k+ Stars and trusted by top-tier projects like Dify, RAGFlow, and Cherry Studio, PaddleOCR is the bedrock for building intelligent RAG and Agentic applications.**
|
||||
|
||||
|
||||
## 🚀 Key Features
|
||||
|
||||
### 📄 Intelligent Document Parsing (LLM-Ready)
|
||||
> *Transforming messy visuals into structured data for the LLM era.*
|
||||
|
||||
* **SOTA Document VLM**: Featuring **PaddleOCR-VL-1.6 (0.9B)**, the industry's leading lightweight vision-language model for document parsing. It achieves 96.3% accuracy on OmniDocBench v1.6, leads in text, formula, and table recognition, and shows significantly enhanced capabilities in ancient documents, rare characters, seals, and charts, with structured outputs in **Markdown** and **JSON** formats.
|
||||
* **Structure-Aware Conversion**: Powered by **PP-StructureV3**, seamlessly convert complex PDFs and images into **Markdown** or **JSON**. Unlike the PaddleOCR-VL series models, it provides more fine-grained coordinate information, including table cell coordinates, text coordinates, and more.
|
||||
* **Production-Ready Efficiency**: Achieve commercial-grade accuracy with an ultra-small footprint. Outperforms numerous closed-source solutions in public benchmarks while remaining resource-efficient for edge/cloud deployment.
|
||||
|
||||
### 🔍 Universal Text Recognition (Scene OCR)
|
||||
> *The global gold standard for high-speed, multilingual text spotting.*
|
||||
|
||||
* **100+ Languages Supported**: Native recognition for a vast global library. **PP-OCRv6** supports 50 languages with a single unified model (Chinese, English, Japanese, and 46 Latin-script languages) — no model switching needed for multilingual documents.
|
||||
* **Complex Element Mastery**: Beyond standard text recognition, we support **natural scene text spotting** across a wide range of environments, including IDs, street views, books, and industrial components
|
||||
* **Performance Leap**: PP-OCRv6 achieves **+4.6% detection** and **+5.1% recognition** accuracy over PP-OCRv5, surpassing mainstream Vision-Language Models. 5.2× CPU inference speedup end-to-end.
|
||||
|
||||
<div align="center">
|
||||
<p>
|
||||
<img width="100%" src="https://raw.githubusercontent.com/cuicheng01/PaddleX_doc_images/main/images/paddleocr/README/Arch.jpg" alt="PaddleOCR Architecture">
|
||||
</p>
|
||||
</div>
|
||||
|
||||
### 🛠️ Developer-Centric Ecosystem
|
||||
* **Seamless Integration**: The premier choice for the AI Agent ecosystem—deeply integrated with **Dify, RAGFlow, Pathway, and Cherry Studio**.
|
||||
* **LLM Data Flywheel**: A complete pipeline to build high-quality datasets, providing a sustainable "Data Engine" for fine-tuning Large Language Models.
|
||||
* **One-Click Deployment**: Supports various hardware backends (NVIDIA GPU, Intel CPU, Kunlunxin XPU, and diverse AI Accelerators).
|
||||
|
||||
|
||||
## 📣 Recent updates
|
||||
|
||||
### 🔥 2026.06.11: Release of PaddleOCR 3.7.0
|
||||
- PP-OCRv6 highlights:
|
||||
|
||||
- **Accuracy boost**: Medium tier achieves +4.6% detection and +5.1% recognition over PP-OCRv5_server, surpassing mainstream VLMs (Qwen3-VL-235B, GPT-5.5) with only 34.5M parameters.
|
||||
- **50 languages unified**: Single model covers Chinese, English, Japanese, and 46 Latin-script languages — no model switching needed.
|
||||
- **Specialized scenarios**: Major improvements in digital displays, dot-matrix characters, tire prints, and industrial text recognition.
|
||||
- **Faster inference**: 5.2× CPU speedup (OpenVINO), 6.1× on Apple M4 (tiny), 0.13s on A100 GPU.
|
||||
- **Three tiers for all scenarios**: tiny (1.5M) / small (7.7M) / medium (34.5M) for edge, mobile, and server deployment.
|
||||
- **Model availability**: All models are available on [HuggingFace](https://huggingface.co/collections/PaddlePaddle/pp-ocrv6) and [ModelScope](https://www.modelscope.cn/collections/PaddlePaddle/PP-OCRv6).
|
||||
|
||||
<details>
|
||||
<summary><strong>2026.05.28: Release of PaddleOCR 3.6.0</strong></summary>
|
||||
|
||||
- PaddleOCR-VL-1.6 highlights:
|
||||
|
||||
- **New SOTA Accuracy**: Achieves over 96.3% on OmniDocBench v1.6, also sets new SOTA on OmniDocBench v1.5 and Real5-OmniDocBench, leading both open-source and proprietary solutions in text, formula, and table recognition.
|
||||
- **Comprehensive Capability Upgrade**: Significant improvements in table, ancient document, and rare character recognition, with notably enhanced seal recognition, spotting, and chart understanding across multiple scenarios.
|
||||
- **Seamless Migration**: Model architecture is fully consistent with PaddleOCR-VL-1.5, enabling zero-cost adaptation—swap and go.
|
||||
- **Try it now**: Available on [HuggingFace](https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.6) or our [Official Website](https://www.paddleocr.com).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>2026.04.21: Release of PaddleOCR 3.5.0</strong></summary>
|
||||
|
||||
* **Flexible inference backends**: Seamlessly switch between Paddle static graph, Paddle dynamic graph, or Transformers. PaddleOCR is now deeply integrated with the Hugging Face ecosystem, and 20 major models support Transformers as the inference backend.
|
||||
* **Office documents to Markdown**: Convert common document formats such as Word, Excel, and PowerPoint into Markdown.
|
||||
* **DOCX export for parsed results**: The `PaddleOCR-VL` series, `PP-StructureV3`, and `PP-DocTranslation` now support exporting parsed results to DOCX for convenient viewing and editing in Microsoft Word.
|
||||
* **Official browser inference SDK**: Released `PaddleOCR.js`, the official browser inference SDK that supports running `PP-OCRv5` directly in the browser.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>2026.01.29: Release of PaddleOCR 3.4.0</strong></summary>
|
||||
|
||||
* PaddleOCR-VL-1.5 (SOTA 0.9B VLM): Our latest flagship model for document parsing is now live!
|
||||
* **94.5% Accuracy on OmniDocBench**: Surpassing top-tier general large models and specialized document parsers.
|
||||
* **Real-World Robustness**: First to introduce the **PP-DocLayoutV3** algorithm for irregular shape positioning, mastering 5 tough scenarios: *Skew, Warping, Scanning, Illumination, and Screen Photography*.
|
||||
* **Capability Expansion**: Now supports **Seal Recognition**, **Text Spotting**, and expands to **111 languages** (including China’s Tibetan script and Bengali).
|
||||
* **Long Document Mastery**: Supports automatic cross-page table merging and hierarchical heading identification.
|
||||
* **Try it now**: Available on [HuggingFace](https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.5) or our [Official Website](https://www.paddleocr.com).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>2025.10.16: Release of PaddleOCR 3.3.0</strong></summary>
|
||||
|
||||
- Released PaddleOCR-VL:
|
||||
- **Model Introduction**:
|
||||
- **PaddleOCR-VL** is a SOTA and resource-efficient model tailored for document parsing. Its core component is PaddleOCR-VL-0.9B, a compact yet powerful vision-language model (VLM) that integrates a NaViT-style dynamic resolution visual encoder with the ERNIE-4.5-0.3B language model to enable accurate element recognition. **This innovative model efficiently supports 109 languages and excels in recognizing complex elements (e.g., text, tables, formulas, and charts), while maintaining minimal resource consumption**. Through comprehensive evaluations on widely used public benchmarks and in-house benchmarks, PaddleOCR-VL achieves SOTA performance in both page-level document parsing and element-level recognition. It significantly outperforms existing solutions, exhibits strong competitiveness against top-tier VLMs, and delivers fast inference speeds. These strengths make it highly suitable for practical deployment in real-world scenarios. The model has been released on [HuggingFace](https://huggingface.co/PaddlePaddle/PaddleOCR-VL). Everyone is welcome to download and use it! More introduction information can be found in [PaddleOCR-VL](https://www.paddleocr.ai/latest/version3.x/algorithm/PaddleOCR-VL/PaddleOCR-VL.html).
|
||||
|
||||
- **Core Features**:
|
||||
- **Compact yet Powerful VLM Architecture**: We present a novel vision-language model that is specifically designed for resource-efficient inference, achieving outstanding performance in element recognition. By integrating a NaViT-style dynamic high-resolution visual encoder with the lightweight ERNIE-4.5-0.3B language model, we significantly enhance the model’s recognition capabilities and decoding efficiency. This integration maintains high accuracy while reducing computational demands, making it well-suited for efficient and practical document processing applications.
|
||||
- **SOTA Performance on Document Parsing**: PaddleOCR-VL achieves state-of-the-art performance in both page-level document parsing and element-level recognition. It significantly outperforms existing pipeline-based solutions and exhibiting strong competitiveness against leading vision-language models (VLMs) in document parsing. Moreover, it excels in recognizing complex document elements, such as text, tables, formulas, and charts, making it suitable for a wide range of challenging content types, including handwritten text and historical documents. This makes it highly versatile and suitable for a wide range of document types and scenarios.
|
||||
- **Multilingual Support**: PaddleOCR-VL Supports 109 languages, covering major global languages, including but not limited to Chinese, English, Japanese, Latin, and Korean, as well as languages with different scripts and structures, such as Russian (Cyrillic script), Arabic, Hindi (Devanagari script), and Thai. This broad language coverage substantially enhances the applicability of our system to multilingual and globalized document processing scenarios.
|
||||
|
||||
- Released PP-OCRv5 Multilingual Recognition Model:
|
||||
- Improved the accuracy and coverage of Latin script recognition; added support for Cyrillic, Arabic, Devanagari, Telugu, Tamil, and other language systems, covering recognition of 109 languages. The model has only 2M parameters, and the accuracy of some models has increased by over 40% compared to the previous generation.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary><strong>2025.08.21: Release of PaddleOCR 3.2.0</strong></summary>
|
||||
|
||||
- **Significant Model Additions:**
|
||||
- Introduced training, inference, and deployment for PP-OCRv5 recognition models in English, Thai, and Greek. **The PP-OCRv5 English model delivers an 11% improvement in English scenarios compared to the main PP-OCRv5 model, with the Thai and Greek recognition models achieving accuracies of 82.68% and 89.28%, respectively.**
|
||||
|
||||
- **Deployment Capability Upgrades:**
|
||||
- **Full support for PaddlePaddle framework versions 3.1.0 and 3.1.1.**
|
||||
- **Comprehensive upgrade of the PP-OCRv5 C++ local deployment solution, now supporting both Linux and Windows, with feature parity and identical accuracy to the Python implementation.**
|
||||
- **High-performance inference now supports CUDA 12, and inference can be performed using either the Paddle Inference or ONNX Runtime backends.**
|
||||
- **The high-stability service-oriented deployment solution is now fully open-sourced, allowing users to customize Docker images and SDKs as required.**
|
||||
- The high-stability service-oriented deployment solution also supports invocation via manually constructed HTTP requests, enabling client-side code development in any programming language.
|
||||
|
||||
- **Benchmark Support:**
|
||||
- **All production lines now support fine-grained benchmarking, enabling measurement of end-to-end inference time as well as per-layer and per-module latency data to assist with performance analysis. [Here's](docs/version3.x/pipeline_usage/instructions/benchmark.en.md) how to set up and use the benchmark feature.**
|
||||
- **Documentation has been updated to include key metrics for commonly used configurations on mainstream hardware, such as inference latency and memory usage, providing deployment references for users.**
|
||||
|
||||
- **Bug Fixes:**
|
||||
- Resolved the issue of failed log saving during model training.
|
||||
- Upgraded the data augmentation component for formula models for compatibility with newer versions of the albumentations dependency, and fixed deadlock warnings when using the tokenizers package in multi-process scenarios.
|
||||
- Fixed inconsistencies in switch behaviors (e.g., `use_chart_parsing`) in the PP-StructureV3 configuration files compared to other pipelines.
|
||||
|
||||
- **Other Enhancements:**
|
||||
- **Separated core and optional dependencies. Only minimal core dependencies are required for basic text recognition; additional dependencies for document parsing and information extraction can be installed as needed.**
|
||||
- **Enabled support for NVIDIA RTX 50 series graphics cards on Windows; users can refer to the [installation guide](docs/version3.x/installation.en.md) for the corresponding PaddlePaddle framework versions.**
|
||||
- **PP-OCR series models now support returning single-character coordinates.**
|
||||
- Added AIStudio, ModelScope, and other model download sources, allowing users to specify the source for model downloads.
|
||||
- Added support for chart-to-table conversion via the PP-Chart2Table module.
|
||||
- Optimized documentation descriptions to improve usability.
|
||||
</details>
|
||||
|
||||
|
||||
[History Log](https://paddlepaddle.github.io/PaddleOCR/latest/en/update/update.html)
|
||||
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Step 1: Try Online
|
||||
PaddleOCR official website provides interactive **Experience Center** and **APIs**—no setup required, just one click to experience.
|
||||
|
||||
👉 [Visit Official Website](https://www.paddleocr.com)
|
||||
|
||||
### Step 2: Local Deployment
|
||||
For local usage, please refer to the following documentation based on your needs:
|
||||
|
||||
- **PP-OCR Series**: See [PP-OCR Documentation](https://www.paddleocr.ai/latest/en/version3.x/pipeline_usage/OCR.html)
|
||||
- **PaddleOCR-VL Series**: See [PaddleOCR-VL Documentation](https://www.paddleocr.ai/latest/en/version3.x/pipeline_usage/PaddleOCR-VL.html)
|
||||
- **PP-StructureV3**: See [PP-StructureV3 Documentation](https://www.paddleocr.ai/latest/en/version3.x/pipeline_usage/PP-StructureV3.html)
|
||||
- **More Capabilities**: See [More Capabilities Documentation](https://www.paddleocr.ai/latest/en/version3.x/pipeline_usage/pipeline_overview.html)
|
||||
|
||||
|
||||
## 🧩 More Features
|
||||
|
||||
- Convert models to ONNX format: [Obtaining ONNX Models](https://paddlepaddle.github.io/PaddleOCR/latest/en/version3.x/inference_deployment/others/obtaining_onnx_models.html).
|
||||
- Accelerate inference using engines like OpenVINO, ONNX Runtime, TensorRT, or perform inference using ONNX format models: [High-Performance Inference](https://paddlepaddle.github.io/PaddleOCR/latest/en/version3.x/inference_deployment/local_inference/high_performance_inference.html).
|
||||
- Accelerate inference using multi-GPU and multi-process: [Parallel Inference for Pipelines](https://paddlepaddle.github.io/PaddleOCR/latest/en/version3.x/pipeline_usage/instructions/parallel_inference.html).
|
||||
- Integrate PaddleOCR into applications written in C++, C#, Java, etc.: [Serving](https://paddlepaddle.github.io/PaddleOCR/latest/en/version3.x/inference_deployment/serving/serving.html).
|
||||
|
||||
## 🔄 Quick Overview of Execution Results
|
||||
|
||||
### PP-OCRv5
|
||||
|
||||
<div align="center">
|
||||
<p>
|
||||
<img width="100%" src="https://raw.githubusercontent.com/cuicheng01/PaddleX_doc_images/main/images/paddleocr/README/PP-OCRv5_demo.gif" alt="PP-OCRv5 Demo">
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
### PP-StructureV3
|
||||
|
||||
<div align="center">
|
||||
<p>
|
||||
<img width="100%" src="https://raw.githubusercontent.com/cuicheng01/PaddleX_doc_images/main/images/paddleocr/README/PP-StructureV3_demo.gif" alt="PP-StructureV3 Demo">
|
||||
</p>
|
||||
</div>
|
||||
|
||||
### PaddleOCR-VL
|
||||
|
||||
<div align="center">
|
||||
<p>
|
||||
<img width="100%" src="https://raw.githubusercontent.com/cuicheng01/PaddleX_doc_images/main/images/paddleocr/README/PaddleOCR-VL_demo.gif" alt="PP-StructureV3 Demo">
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
## ✨ Stay Tuned
|
||||
|
||||
⭐ **Star this repository to keep up with exciting updates and new releases, including powerful OCR and document parsing capabilities!** ⭐
|
||||
|
||||
<div align="center">
|
||||
<p>
|
||||
<img width="1200" src="https://raw.githubusercontent.com/cuicheng01/PaddleX_doc_images/main/images/paddleocr/README/star_paddleocr2.en.gif" alt="Star-Project">
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
## 👩👩👧👦 Community
|
||||
|
||||
<div align="center">
|
||||
|
||||
| PaddlePaddle WeChat official account | Join the tech discussion group |
|
||||
| :---: | :---: |
|
||||
| <img src="https://raw.githubusercontent.com/cuicheng01/PaddleX_doc_images/refs/heads/main/images/paddleocr/README/qrcode_for_paddlepaddle_official_account.jpg" width="150"> | <img src="https://raw.githubusercontent.com/cuicheng01/PaddleX_doc_images/refs/heads/main/images/paddleocr/README/qr_code_for_the_questionnaire.jpg" width="150"> |
|
||||
</div>
|
||||
|
||||
|
||||
## 😃 Awesome Projects Leveraging PaddleOCR
|
||||
PaddleOCR wouldn't be where it is today without its incredible community! 💗 A massive thank you to all our longtime partners, new collaborators, and everyone who's poured their passion into PaddleOCR — whether we've named you or not. Your support fuels our fire!
|
||||
|
||||
<div align="center">
|
||||
|
||||
| Project Name | Description |
|
||||
| ------------ | ----------- |
|
||||
| [Dify](https://github.com/langgenius/dify) <a href="https://github.com/langgenius/dify"><img src="https://img.shields.io/github/stars/langgenius/dify"></a>|Production-ready platform for agentic workflow development.|
|
||||
| [RAGFlow](https://github.com/infiniflow/ragflow) <a href="https://github.com/infiniflow/ragflow"><img src="https://img.shields.io/github/stars/infiniflow/ragflow"></a>|RAG engine based on deep document understanding.|
|
||||
| [pathway](https://github.com/pathwaycom/pathway) <a href="https://github.com/pathwaycom/pathway"><img src="https://img.shields.io/github/stars/pathwaycom/pathway"></a>|Python ETL framework for stream processing, real-time analytics, LLM pipelines, and RAG.|
|
||||
| [MinerU](https://github.com/opendatalab/MinerU) <a href="https://github.com/opendatalab/MinerU"><img src="https://img.shields.io/github/stars/opendatalab/MinerU"></a>|Multi-type Document to Markdown Conversion Tool|
|
||||
| [Umi-OCR](https://github.com/hiroi-sora/Umi-OCR) <a href="https://github.com/hiroi-sora/Umi-OCR"><img src="https://img.shields.io/github/stars/hiroi-sora/Umi-OCR"></a>|Free, Open-source, Batch Offline OCR Software.|
|
||||
| [cherry-studio](https://github.com/CherryHQ/cherry-studio) <a href="https://github.com/CherryHQ/cherry-studio"><img src="https://img.shields.io/github/stars/CherryHQ/cherry-studio"></a>|A desktop client that supports for multiple LLM providers.|
|
||||
| [haystack](https://github.com/deepset-ai/haystack)<a href="https://github.com/deepset-ai/haystack"><img src="https://img.shields.io/github/stars/deepset-ai/haystack"></a> |AI orchestration framework to build customizable, production-ready LLM applications.|
|
||||
| [OmniParser](https://github.com/microsoft/OmniParser)<a href="https://github.com/microsoft/OmniParser"><img src="https://img.shields.io/github/stars/microsoft/OmniParser"></a> |OmniParser: Screen Parsing tool for Pure Vision Based GUI Agent.|
|
||||
| [QAnything](https://github.com/netease-youdao/QAnything)<a href="https://github.com/netease-youdao/QAnything"><img src="https://img.shields.io/github/stars/netease-youdao/QAnything"></a> |Question and Answer based on Anything.|
|
||||
| [Learn more projects](./awesome_projects.md) | [More projects based on PaddleOCR](./awesome_projects.md)|
|
||||
</div>
|
||||
|
||||
## 👩👩👧👦 Contributors
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/PaddlePaddle/PaddleOCR/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=PaddlePaddle/PaddleOCR&max=400&columns=20" width="800"/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## 🌟 Star
|
||||
|
||||
<div align="center">
|
||||
<p>
|
||||
<img width="800" src="https://api.star-history.com/svg?repos=PaddlePaddle/PaddleOCR&type=Date" alt="Star-history">
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
## 📄 License
|
||||
This project is released under the [Apache 2.0 license](LICENSE).
|
||||
|
||||
## 🎓 Citation
|
||||
|
||||
```bibtex
|
||||
@misc{cui2025paddleocr30technicalreport,
|
||||
title={PaddleOCR 3.0 Technical Report},
|
||||
author={Cheng Cui and Ting Sun and Manhui Lin and Tingquan Gao and Yubo Zhang and Jiaxuan Liu and Xueqing Wang and Zelun Zhang and Changda Zhou and Hongen Liu and Yue Zhang and Wenyu Lv and Kui Huang and Yichao Zhang and Jing Zhang and Jun Zhang and Yi Liu and Dianhai Yu and Yanjun Ma},
|
||||
year={2025},
|
||||
eprint={2507.05595},
|
||||
archivePrefix={arXiv},
|
||||
primaryClass={cs.CV},
|
||||
url={https://arxiv.org/abs/2507.05595},
|
||||
}
|
||||
|
||||
@misc{cui2025paddleocrvlboostingmultilingualdocument,
|
||||
title={PaddleOCR-VL: Boosting Multilingual Document Parsing via a 0.9B Ultra-Compact Vision-Language Model},
|
||||
author={Cheng Cui and Ting Sun and Suyin Liang and Tingquan Gao and Zelun Zhang and Jiaxuan Liu and Xueqing Wang and Changda Zhou and Hongen Liu and Manhui Lin and Yue Zhang and Yubo Zhang and Handong Zheng and Jing Zhang and Jun Zhang and Yi Liu and Dianhai Yu and Yanjun Ma},
|
||||
year={2025},
|
||||
eprint={2510.14528},
|
||||
archivePrefix={arXiv},
|
||||
primaryClass={cs.CV},
|
||||
url={https://arxiv.org/abs/2510.14528},
|
||||
}
|
||||
|
||||
@misc{cui2026paddleocrvl15multitask09bvlm,
|
||||
title={PaddleOCR-VL-1.5: Towards a Multi-Task 0.9B VLM for Robust In-the-Wild Document Parsing},
|
||||
author={Cheng Cui and Ting Sun and Suyin Liang and Tingquan Gao and Zelun Zhang and Jiaxuan Liu and Xueqing Wang and Changda Zhou and Hongen Liu and Manhui Lin and Yue Zhang and Yubo Zhang and Yi Liu and Dianhai Yu and Yanjun Ma},
|
||||
year={2026},
|
||||
eprint={2601.21957},
|
||||
archivePrefix={arXiv},
|
||||
primaryClass={cs.CV},
|
||||
url={https://arxiv.org/abs/2601.21957},
|
||||
}
|
||||
|
||||
@misc{zhang2026paddleocrvl16expandingfrontierdocument,
|
||||
title={PaddleOCR-VL-1.6: Expanding the Frontier of Document Parsing with Under-Optimized Region Refinement and Progressive Post-Training},
|
||||
author={Zelun Zhang and Hongen Liu and Suyin Liang and Yubo Zhang and Yiqing Xiang and Jiaxuan Liu and Ting Sun and Manhui Lin and Yue Zhang and Changda Zhou and Tingquan Gao and Cheng Cui and Yi Liu and Dianhai Yu and Yanjun Ma},
|
||||
year={2026},
|
||||
eprint={2606.03264},
|
||||
archivePrefix={arXiv},
|
||||
primaryClass={cs.CV},
|
||||
url={https://arxiv.org/abs/2606.03264},
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`PaddlePaddle/PaddleOCR`
|
||||
- 原始仓库:https://github.com/PaddlePaddle/PaddleOCR
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
# PaddleOCR Release SOP
|
||||
|
||||
[中文](./RELEASING_cn.md)
|
||||
|
||||
## Scope
|
||||
|
||||
This document describes the standard release process for PaddleOCR.
|
||||
|
||||
## Release Types
|
||||
|
||||
The current process supports the following two release types:
|
||||
|
||||
- `bump patch`: for example, `3.4.0 -> 3.4.1`
|
||||
- `bump minor`: for example, `3.4.x -> 3.5.0`
|
||||
|
||||
For `bump major`:
|
||||
|
||||
- The current process does not directly support this scenario
|
||||
- A major release process should be discussed and designed separately, for example by introducing an additional branch or a new preparation flow
|
||||
|
||||
## Release Principles
|
||||
|
||||
- Daily development happens on `main`
|
||||
- Official releases are made only from `release/X.Y`
|
||||
- Official tags must use `vX.Y.Z`
|
||||
- Do not create official release tags on `main`
|
||||
|
||||
## Standard Release Process
|
||||
|
||||
### 1. Confirm the release target
|
||||
|
||||
First confirm which release line this release belongs to and what the target version is.
|
||||
|
||||
Examples:
|
||||
|
||||
- Release `3.4.1` on `release/3.4`
|
||||
- Release `3.5.0` on `release/3.5`
|
||||
|
||||
### 2. Create or switch to the release branch
|
||||
|
||||
If the release line does not exist yet, create the corresponding `release/X.Y` branch from `main`.
|
||||
If it already exists, switch to that branch and continue release preparation there.
|
||||
|
||||
Requirements:
|
||||
|
||||
- One minor release line corresponds to one fixed `release/X.Y` branch
|
||||
- That branch should contain only the release content and patch fixes for that line
|
||||
|
||||
### 3. Pick release content from main
|
||||
|
||||
Based on the release scope, `cherry-pick` the required commits from `main` into `release/X.Y` until the release content is ready.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Only pick what is needed for the current release
|
||||
- Avoid bringing unrelated new features into the release branch
|
||||
- If there are release-specific fixes on the release branch, keep them limited to the current release scope
|
||||
|
||||
### 4. Complete pre-release checks
|
||||
|
||||
Complete pre-release checks on `release/X.Y`.
|
||||
|
||||
At minimum, this should include:
|
||||
|
||||
- The current branch is correct
|
||||
- The working tree is clean
|
||||
- The version is as expected
|
||||
- Key functionality is verified
|
||||
- Required tests, builds, packaging, and regression checks have passed
|
||||
- Release notes are ready
|
||||
|
||||
### 5. Create the official tag
|
||||
|
||||
Once the release is ready, create the official tag on `release/X.Y`:
|
||||
|
||||
- The tag format must be `vX.Y.Z`
|
||||
|
||||
Examples:
|
||||
|
||||
- `v3.4.1`
|
||||
- `v3.5.0`
|
||||
|
||||
Requirements:
|
||||
|
||||
- The tag must be created on the release branch for the current official release
|
||||
- Do not use development tags as official release tags
|
||||
|
||||
### 6. Publish the GitHub Release
|
||||
|
||||
Create a GitHub Release based on the official tag for this release.
|
||||
|
||||
### 7. Update dependency constraints and release notes
|
||||
|
||||
If this release is the first release of a new minor line, or if it changes PaddleX dependency requirements, update the related release materials before or after the official tag is published.
|
||||
|
||||
At minimum, this should include:
|
||||
|
||||
- Checking whether the `paddlex` dependency constraints in `pyproject.toml` match the target release
|
||||
- Checking whether installation docs, upgrade notes, and release notes are aligned with the release version
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- The `paddlex` dependency constraints match the target release
|
||||
- The version information in the documentation matches the released version
|
||||
|
||||
### 8. Sync the release branch lineage back to main
|
||||
|
||||
After the first official release of a new `release/X.Y` line is completed, sync the lineage of that `release/X.Y` branch back to `main`.
|
||||
|
||||
This is a fixed step in the current workflow and should be done at least once for each new minor release line.
|
||||
|
||||
Purpose:
|
||||
|
||||
- Ensure `main` correctly reflects that the release line has produced an official version
|
||||
- Keep subsequent development and release cadence aligned
|
||||
|
||||
Requirements:
|
||||
|
||||
- Perform this once after the first official release of each new `release/X.Y`
|
||||
- For later patch releases on the same `release/X.Y`, it is usually not necessary to repeat it
|
||||
|
||||
### 9. Move on to the next development cycle or patch release
|
||||
|
||||
After the release:
|
||||
|
||||
- `main` continues with ongoing development
|
||||
- `release/X.Y` continues to maintain that release line
|
||||
|
||||
If more patch releases are needed later on the same line:
|
||||
|
||||
- Continue preparing patches on `release/X.Y`
|
||||
- `cherry-pick` from `main` as needed
|
||||
- Repeat the relevant steps in this SOP
|
||||
|
||||
## How to Handle Different Bump Types
|
||||
|
||||
### Patch Release
|
||||
|
||||
Applicable scenarios:
|
||||
|
||||
- Fixing production issues
|
||||
- Small compatibility fixes
|
||||
- Documentation, dependency, or stability patches
|
||||
|
||||
How to handle it:
|
||||
|
||||
- Continue preparing changes on the existing `release/X.Y`
|
||||
- Create the next patch tag, for example `v3.4.2`
|
||||
|
||||
### Minor Release
|
||||
|
||||
Applicable scenarios:
|
||||
|
||||
- Starting a new release line
|
||||
- Releasing the next stable version, for example `3.5.0`
|
||||
|
||||
How to handle it:
|
||||
|
||||
- Create a new `release/X.Y` from `main`
|
||||
- Prepare the release following the standard process
|
||||
- Create the first official tag, for example `v3.5.0`
|
||||
- Update `paddlex` dependency constraints if needed
|
||||
- Sync the lineage of that release branch back to `main` after the release
|
||||
|
||||
### Major Release
|
||||
|
||||
Current conclusion:
|
||||
|
||||
- It is not included in this SOP for now
|
||||
- A separate process needs to be discussed and designed later
|
||||
|
||||
Before a dedicated solution is defined, do not directly reuse the current minor/patch process for a major release.
|
||||
|
||||
## Release Checklist
|
||||
|
||||
Before each release, confirm the following:
|
||||
|
||||
- The target version and corresponding release branch have been confirmed
|
||||
- The content on the release branch is ready
|
||||
- The release scope has been finalized
|
||||
- Key tests and regression checks have passed
|
||||
- Release notes are ready
|
||||
- The official tag uses the `vX.Y.Z` format
|
||||
- The GitHub Release has been created
|
||||
- If this is the first official release on `release/X.Y`, its lineage has been synced back to `main`
|
||||
|
||||
## Daily Maintenance Recommendations
|
||||
|
||||
- New features should go to `main` first
|
||||
- Official releases should always be performed through `release/X.Y`
|
||||
- Patch releases should always be maintained on the corresponding `release/X.Y`
|
||||
- For each new `release/X.Y`, sync its lineage once after the first official release
|
||||
|
||||
If the current process changes, this document should be updated accordingly.
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
# PaddleOCR Release SOP
|
||||
|
||||
[English](./RELEASING.md)
|
||||
|
||||
## 适用范围
|
||||
|
||||
本文档用于说明 PaddleOCR 的标准发版流程。
|
||||
|
||||
## 版本类型
|
||||
|
||||
当前流程支持以下两类发布:
|
||||
|
||||
- `bump patch`:例如 `3.4.0 -> 3.4.1`
|
||||
- `bump minor`:例如 `3.4.x -> 3.5.0`
|
||||
|
||||
对于 `bump major`:
|
||||
|
||||
- 当前流程暂不支持直接覆盖这类场景
|
||||
- 如需发布大版本,需要单独讨论并设计额外流程,例如引入额外分支或新的版本准备方式
|
||||
|
||||
## 发版原则
|
||||
|
||||
- 日常开发在 `main` 分支进行
|
||||
- 正式发布只在 `release/X.Y` 分支进行
|
||||
- 正式标签只使用 `vX.Y.Z`
|
||||
- 不在 `main` 分支打正式发布标签
|
||||
|
||||
## 标准发版流程
|
||||
|
||||
### 1. 确认发布目标
|
||||
|
||||
先确认本次发布属于哪一条版本线,以及目标版本号。
|
||||
|
||||
示例:
|
||||
|
||||
- 发布 `3.4.1`,对应分支为 `release/3.4`
|
||||
- 发布 `3.5.0`,对应分支为 `release/3.5`
|
||||
|
||||
### 2. 创建或切换到 release 分支
|
||||
|
||||
如果该版本线尚未建立,则从 `main` 创建对应的 `release/X.Y` 分支。
|
||||
如果该版本线已存在,则直接切换到该分支继续发版准备。
|
||||
|
||||
要求:
|
||||
|
||||
- 一个 minor 版本线对应一个固定的 `release/X.Y` 分支
|
||||
- 该分支只承载该版本线的发布内容和补丁修复
|
||||
|
||||
### 3. 从 main 挑选发布内容
|
||||
|
||||
根据本次发布范围,从 `main` 将需要发布的提交按需 `cherry-pick` 到 `release/X.Y`,直到版本内容 ready。
|
||||
|
||||
要求:
|
||||
|
||||
- 只挑选本次发布需要的内容
|
||||
- 避免把与本次发布无关的新功能带入 release 分支
|
||||
- 如果 release 分支上出现专门的发布修复,也应仅限于本次发布范围
|
||||
|
||||
### 4. 完成发布前检查
|
||||
|
||||
在 `release/X.Y` 上完成发布前检查。
|
||||
|
||||
至少应包括:
|
||||
|
||||
- 当前分支正确
|
||||
- 工作区干净
|
||||
- 版本号符合预期
|
||||
- 关键功能验证通过
|
||||
- 必要的测试、构建、打包和回归通过
|
||||
- 发布说明已经准备好
|
||||
|
||||
### 5. 打正式标签
|
||||
|
||||
确认版本 ready 后,在 `release/X.Y` 分支上打正式标签:
|
||||
|
||||
- 标签格式必须为 `vX.Y.Z`
|
||||
|
||||
示例:
|
||||
|
||||
- `v3.4.1`
|
||||
- `v3.5.0`
|
||||
|
||||
要求:
|
||||
|
||||
- 标签必须打在本次正式发布对应的 release 分支上
|
||||
- 不使用开发态标签作为正式发布标签
|
||||
|
||||
### 6. 发布 GitHub Release
|
||||
|
||||
在 GitHub 上基于本次正式标签创建 Release。
|
||||
|
||||
### 7. 更新依赖约束与发布说明
|
||||
|
||||
如果本次发布是新的 minor 版本首发,或本次发布涉及 PaddleX 依赖变化,则在正式标签发布前后同步完成相关更新。
|
||||
|
||||
至少应包括:
|
||||
|
||||
- 检查 `pyproject.toml` 中 `paddlex` 相关依赖约束是否与本次发布版本匹配
|
||||
- 检查安装说明、升级说明和发布说明中的版本信息是否与本次发布一致
|
||||
|
||||
完成要求:
|
||||
|
||||
- `paddlex` 相关依赖约束与本次发布目标一致
|
||||
- 文档中的版本信息与本次发布版本一致
|
||||
|
||||
### 8. 将 release 分支的 lineage 同步回 main
|
||||
|
||||
当一条新的 `release/X.Y` 发布线完成首个正式版本发布后,需要将该 `release/X.Y` 的 lineage 同步回 `main`。
|
||||
|
||||
这一步是当前流程中的固定步骤,每条新的 minor 发布线至少执行一次。
|
||||
|
||||
目的:
|
||||
|
||||
- 让 `main` 正确感知该发布线已经完成的正式版本
|
||||
- 保持后续开发与发布节奏一致
|
||||
|
||||
要求:
|
||||
|
||||
- 每条新的 `release/X.Y` 在首个正式版本发布后执行一次
|
||||
- 同一条 `release/X.Y` 后续如果继续发布补丁版本,通常不要求重复执行
|
||||
|
||||
### 9. 进入下一轮开发或补丁发布
|
||||
|
||||
发布完成后:
|
||||
|
||||
- `main` 继续进行后续开发
|
||||
- `release/X.Y` 继续维护该版本线
|
||||
|
||||
如果后续还要发布该版本线的补丁版本:
|
||||
|
||||
- 继续在 `release/X.Y` 上准备补丁
|
||||
- 从 `main` 按需 `cherry-pick`
|
||||
- 重复执行本 SOP 中的发布步骤
|
||||
|
||||
## 不同 bump 类型的处理方式
|
||||
|
||||
### Patch 发布
|
||||
|
||||
适用场景:
|
||||
|
||||
- 修复线上问题
|
||||
- 小范围兼容性修复
|
||||
- 文档、依赖或稳定性补丁
|
||||
|
||||
处理方式:
|
||||
|
||||
- 在现有 `release/X.Y` 分支上继续准备内容
|
||||
- 打下一个 patch 标签,例如 `v3.4.2`
|
||||
|
||||
### Minor 发布
|
||||
|
||||
适用场景:
|
||||
|
||||
- 开启一条新的发布线
|
||||
- 发布下一阶段稳定版本,例如 `3.5.0`
|
||||
|
||||
处理方式:
|
||||
|
||||
- 从 `main` 建立新的 `release/X.Y`
|
||||
- 按标准流程准备版本内容
|
||||
- 打首个正式标签,例如 `v3.5.0`
|
||||
- 如有需要,同步更新 `paddlex` 相关依赖约束
|
||||
- 发布后将该 release 分支的 lineage 同步回 `main`
|
||||
|
||||
### Major 发布
|
||||
|
||||
当前结论:
|
||||
|
||||
- 暂不纳入本 SOP
|
||||
- 后续需要单独讨论并设计流程
|
||||
|
||||
在新的方案明确之前,不建议直接套用当前 minor/patch 的做法处理 major 发布。
|
||||
|
||||
## 发布检查清单
|
||||
|
||||
每次发版前,请确认以下事项:
|
||||
|
||||
- 已确认目标版本号和对应 release 分支
|
||||
- release 分支中的内容已经 ready
|
||||
- 发布范围已经收敛
|
||||
- 关键测试和回归已通过
|
||||
- 发布说明已准备完成
|
||||
- 正式标签格式为 `vX.Y.Z`
|
||||
- GitHub Release 已创建
|
||||
- 如本次是该 `release/X.Y` 的首个正式版本,发布完成后已将 lineage 同步回 `main`
|
||||
|
||||
## 日常维护建议
|
||||
|
||||
- 新功能优先进入 `main`
|
||||
- 正式发布始终通过 `release/X.Y` 执行
|
||||
- 补丁版本始终在对应的 `release/X.Y` 上维护
|
||||
- 每条新的 `release/X.Y` 在首个正式版本发布后执行一次 lineage 同步
|
||||
|
||||
如当前流程发生调整,应同步更新本文档。
|
||||
@@ -0,0 +1,71 @@
|
||||
# PaddleOCR API SDK 集成测试报告
|
||||
|
||||
**测试分支**: feature/api-sdk (PR #18049)
|
||||
**测试环境**: macOS Darwin 24.3.0, Python 3.9.6, requests 2.32.5
|
||||
**API Endpoint**: https://paddleocr.aistudio-app.com/api/v2/ocr/jobs
|
||||
|
||||
---
|
||||
|
||||
## 测试结果总览
|
||||
|
||||
| # | 测试项 | 结果 | 耗时 | 说明 |
|
||||
| -----| ---------------------------------------| --------| ------| ---------------------------------------------------|
|
||||
| 1 | OCR URL (PP-OCRv5) | ✅ PASS | 3.6s | URL 输入,默认参数 |
|
||||
| 2 | OCR URL + 自定义 Options | ✅ PASS | 3.6s | 设置 use_doc_orientation_classify=True |
|
||||
| 3 | Doc Parsing URL (PP-StructureV3) | ✅ PASS | 3.7s | 文档版面解析 |
|
||||
| 4 | Submit + Poll 分步调用 | ✅ PASS | 3.7s | 非阻塞 API:submit → get_result → wait_for_result |
|
||||
| 5 | OCR 本地文件上传 | ✅ PASS | 4.3s | file_path 模式 |
|
||||
| 6 | 错误处理 (无效 token) | ✅ PASS | 0.2s | 正确抛出 AuthError |
|
||||
| 7 | 输入校验 | ✅ PASS | 0.0s | 缺少输入 / 互斥参数均正确拦截 |
|
||||
| 8 | Context Manager (with) | ✅ PASS | 3.6s | with 语句正常工作 |
|
||||
| 9 | PaddleOCR-VL 模型 | ✅ PASS | 3.6s | VL 模型正常返回 markdown |
|
||||
| 10 | PaddleOCR-VL-1.5 模型 | ✅ PASS | 3.6s | VL-1.5 模型正常返回 markdown |
|
||||
| 11 | Doc Parsing 文件上传 (PP-StructureV3) | ✅ PASS | 4.3s | 本地文件上传 + 文档解析 |
|
||||
|
||||
**总计: 11 passed, 0 failed**
|
||||
|
||||
---
|
||||
|
||||
## 发现的 Bug(已修复验证)
|
||||
|
||||
### 🔴 阻塞性 Bug: `fetch_jsonl` 请求 BOS 签名 URL 时携带了多余的 Authorization header
|
||||
|
||||
**文件**: `paddleocr/_api_client/_http.py` (同步) + `async_client.py` (异步)
|
||||
|
||||
**现象**: 任务提交和轮询均成功,但在最后一步下载 JSONL 结果文件时,SDK 使用带有 `Authorization: bearer <paddle_token>` 的 session 去请求百度 BOS 对象存储的预签名 URL。BOS 不认识这个 header,返回 400 Bad Request。
|
||||
|
||||
**根因**: `HTTPClient.fetch_jsonl()` 使用 `self._session.get(url)` 发起请求,而 session 在初始化时设置了 `Authorization` header。BOS 的预签名 URL 已经包含了自己的鉴权参数(`authorization=bce-auth-v1/...`),额外的 Authorization header 导致冲突。
|
||||
|
||||
**修复**: `fetch_jsonl` 应使用不带 auth header 的独立请求:
|
||||
```python
|
||||
# 修复前
|
||||
resp = self._session.get(url, timeout=self._timeout)
|
||||
|
||||
# 修复后
|
||||
resp = requests.get(url, timeout=self._timeout)
|
||||
```
|
||||
|
||||
**影响范围**: 所有实际 API 调用(OCR、Doc Parsing、所有模型)在修复前均无法获取结果。这是一个 **必须在合入前修复的阻塞性 bug**。
|
||||
|
||||
---
|
||||
|
||||
## 其他已知问题(非阻塞,可后续迭代)
|
||||
|
||||
| 严重度 | 语言 | 问题 |
|
||||
|--------|------|------|
|
||||
| 中 | Python | AsyncAPIClient._poll_until_done 使用硬编码 DEFAULT_MAX_WAIT_TIME,忽略用户设置的 timeout |
|
||||
| 中 | Go | submitURL/submitFile/getJobStatus 未使用 http.NewRequestWithContext,context 取消无法中断 HTTP 请求 |
|
||||
| 中 | TypeScript | poller.ts 的 sleep 方法 abort listener 未设置 `{ once: true }`,长轮询会泄漏 listener |
|
||||
| 中 | TypeScript | http.ts 的 fetchJsonl 未检查 resp.ok(同样的 BOS auth 问题可能也存在于 TS SDK) |
|
||||
| 低 | 全部 | 轮询循环先 sleep 再 check,对已完成任务多等 3 秒 |
|
||||
| 低 | Python | CLI argparse 的 store_true 传 False 而非 None,导致多余字段发送给 API |
|
||||
|
||||
---
|
||||
|
||||
## 结论
|
||||
|
||||
**修复 `fetch_jsonl` 的 auth header 问题后,Python SDK 的核心功能全部正常工作。** 4 个模型(PP-OCRv5、PP-StructureV3、PaddleOCR-VL、PaddleOCR-VL-1.5)均可正常调用,URL 输入和文件上传两种模式均可用,错误处理和输入校验逻辑正确。
|
||||
|
||||
**建议**:
|
||||
1. ⚠️ **合入前必须修复** `fetch_jsonl` 的 BOS auth header bug(Python 同步 + 异步,以及 Go/TypeScript SDK 中的同类问题)
|
||||
2. 其他问题可以合入后迭代修复
|
||||
@@ -0,0 +1,50 @@
|
||||
# PaddleOCR official API SDKs
|
||||
|
||||
English | [简体中文](README_cn.md)
|
||||
|
||||
This directory contains source-adjacent maintainer files for the PaddleOCR
|
||||
official API SDKs. The SDKs call hosted PaddleOCR official API services; they do
|
||||
not run local PaddleOCR inference or load local models.
|
||||
|
||||
The official user documentation:
|
||||
|
||||
- [Overview](../docs/version3.x/inference_deployment/serving/paddleocr_official_api/overview.md)
|
||||
- [Python SDK](../docs/version3.x/inference_deployment/serving/paddleocr_official_api/python.md)
|
||||
- [TypeScript SDK](../docs/version3.x/inference_deployment/serving/paddleocr_official_api/typescript.md)
|
||||
- [Go SDK](../docs/version3.x/inference_deployment/serving/paddleocr_official_api/go.md)
|
||||
- [CLI](../docs/version3.x/inference_deployment/serving/paddleocr_official_api/cli.md)
|
||||
|
||||
## Maintainer Files
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| [`typescript/README.md`](typescript/README.md) | Package-level README for the TypeScript SDK. |
|
||||
| [`go/README.md`](go/README.md) | Package-level README for the Go SDK. |
|
||||
|
||||
The Python SDK is part of the main `paddleocr` package.
|
||||
|
||||
## Package Locations
|
||||
|
||||
| Language | Source location | User docs |
|
||||
| --- | --- | --- |
|
||||
| Python | [`../paddleocr`](../paddleocr) | [Python SDK](../docs/version3.x/inference_deployment/serving/paddleocr_official_api/python.md) |
|
||||
| TypeScript | [`typescript`](typescript) | [TypeScript SDK](../docs/version3.x/inference_deployment/serving/paddleocr_official_api/typescript.md) |
|
||||
| Go | [`go`](go) | [Go SDK](../docs/version3.x/inference_deployment/serving/paddleocr_official_api/go.md) |
|
||||
|
||||
## Validation
|
||||
|
||||
Run these from the PaddleOCR repo root directory:
|
||||
|
||||
```bash
|
||||
# Python
|
||||
python -m pytest tests/api_client/
|
||||
|
||||
# TypeScript
|
||||
cd api_sdk/typescript
|
||||
npm run lint
|
||||
npm test
|
||||
|
||||
# Go
|
||||
cd ../go
|
||||
go test ./...
|
||||
```
|
||||
@@ -0,0 +1,48 @@
|
||||
# PaddleOCR 官方 API SDK
|
||||
|
||||
[English](README.md) | 简体中文
|
||||
|
||||
本目录包含 PaddleOCR 官方 API SDK 的源码相邻维护文档。SDK 调用 PaddleOCR 官方 API 托管服务;它们不在本地执行 PaddleOCR 推理,也不加载本地模型。
|
||||
|
||||
正式用户文档:
|
||||
|
||||
- [总览](../docs/version3.x/inference_deployment/serving/paddleocr_official_api/overview.md)
|
||||
- [Python SDK](../docs/version3.x/inference_deployment/serving/paddleocr_official_api/python.md)
|
||||
- [TypeScript SDK](../docs/version3.x/inference_deployment/serving/paddleocr_official_api/typescript.md)
|
||||
- [Go SDK](../docs/version3.x/inference_deployment/serving/paddleocr_official_api/go.md)
|
||||
- [CLI](../docs/version3.x/inference_deployment/serving/paddleocr_official_api/cli.md)
|
||||
|
||||
## 维护者文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
| --- | --- |
|
||||
| [`typescript/README_cn.md`](typescript/README_cn.md) | TypeScript SDK 的包级 README。 |
|
||||
| [`go/README_cn.md`](go/README_cn.md) | Go SDK 的包级 README。 |
|
||||
|
||||
Python SDK 是主 `paddleocr` 包的一部分。
|
||||
|
||||
## 包位置
|
||||
|
||||
| 语言 | 源码位置 | 用户文档 |
|
||||
| --- | --- | --- |
|
||||
| Python | [`../paddleocr`](../paddleocr) | [Python SDK](../docs/version3.x/inference_deployment/serving/paddleocr_official_api/python.md) |
|
||||
| TypeScript | [`typescript`](typescript) | [TypeScript SDK](../docs/version3.x/inference_deployment/serving/paddleocr_official_api/typescript.md) |
|
||||
| Go | [`go`](go) | [Go SDK](../docs/version3.x/inference_deployment/serving/paddleocr_official_api/go.md) |
|
||||
|
||||
## 验证
|
||||
|
||||
在 PaddleOCR 仓库根目录执行:
|
||||
|
||||
```bash
|
||||
# Python
|
||||
python -m pytest tests/api_client/
|
||||
|
||||
# TypeScript
|
||||
cd api_sdk/typescript
|
||||
npm run lint
|
||||
npm test
|
||||
|
||||
# Go
|
||||
cd ../go
|
||||
go test ./...
|
||||
```
|
||||
@@ -0,0 +1,72 @@
|
||||
# PaddleOCR Go SDK
|
||||
|
||||
English | [简体中文](README_cn.md)
|
||||
|
||||
Go client for the PaddleOCR official API. It submits OCR and document parsing
|
||||
jobs to hosted PaddleOCR services; it does not run local PaddleOCR inference or
|
||||
load local models.
|
||||
|
||||
Official user docs:
|
||||
|
||||
- [Go SDK](../../docs/version3.x/inference_deployment/serving/paddleocr_official_api/go.md)
|
||||
- [Go SDK (English)](../../docs/version3.x/inference_deployment/serving/paddleocr_official_api/go.en.md)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
go get github.com/PaddlePaddle/PaddleOCR/api_sdk/go
|
||||
```
|
||||
|
||||
Versioned releases use submodule tags such as `api_sdk/go/v0.1.0`.
|
||||
|
||||
## Minimal Usage
|
||||
|
||||
Set `PADDLEOCR_ACCESS_TOKEN` or pass `WithToken` when constructing the client:
|
||||
|
||||
```bash
|
||||
export PADDLEOCR_ACCESS_TOKEN="your-access-token"
|
||||
```
|
||||
|
||||
```go
|
||||
client, err := paddleocr.NewClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := client.OCR(ctx, &paddleocr.OCRRequest{
|
||||
Model: paddleocr.PPOCRv5,
|
||||
FileURL: "https://example.com/invoice.pdf",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(result.JobID, len(result.Pages))
|
||||
```
|
||||
|
||||
Set `Model: paddleocr.PPOCRv6` (or `"PP-OCRv6"`) to use the PP-OCRv6 hosted OCR model.
|
||||
Set `Model: paddleocr.PPOCRv5Latin` (or `"PP-OCRv5-latin"`) to use the PP-OCRv5 Latin-script hosted OCR model.
|
||||
|
||||
Document parsing defaults to PaddleOCR-VL-1.6:
|
||||
|
||||
```go
|
||||
doc, err := client.ParseDocument(ctx, &paddleocr.DocParsingRequest{
|
||||
FilePath: "./report.pdf",
|
||||
Options: &paddleocr.PaddleOCRVLOptions{
|
||||
UseChartRecognition: paddleocr.Bool(true),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(doc.JobID, len(doc.Pages))
|
||||
```
|
||||
|
||||
## Build And Test
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go test -race ./...
|
||||
```
|
||||
|
||||
`go test -race ./...` is recommended before public release.
|
||||
@@ -0,0 +1,70 @@
|
||||
# PaddleOCR Go SDK
|
||||
|
||||
[English](README.md) | 简体中文
|
||||
|
||||
面向 PaddleOCR 官方 API 的 Go 客户端。它会把 OCR 和文档解析任务提交到 PaddleOCR 官方托管服务;不会运行本地 PaddleOCR 推理,也不会加载本地模型。
|
||||
|
||||
正式用户文档:
|
||||
|
||||
- [Go SDK](../../docs/version3.x/inference_deployment/serving/paddleocr_official_api/go.md)
|
||||
- [Go SDK(英文)](../../docs/version3.x/inference_deployment/serving/paddleocr_official_api/go.en.md)
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
go get github.com/PaddlePaddle/PaddleOCR/api_sdk/go
|
||||
```
|
||||
|
||||
版本化发布使用 `api_sdk/go/v0.1.0` 这类子目录 module tag。
|
||||
|
||||
## 最小示例
|
||||
|
||||
设置 `PADDLEOCR_ACCESS_TOKEN`,或在构造客户端时传入 `WithToken`:
|
||||
|
||||
```bash
|
||||
export PADDLEOCR_ACCESS_TOKEN="your-access-token"
|
||||
```
|
||||
|
||||
```go
|
||||
client, err := paddleocr.NewClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := client.OCR(ctx, &paddleocr.OCRRequest{
|
||||
Model: paddleocr.PPOCRv5,
|
||||
FileURL: "https://example.com/invoice.pdf",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(result.JobID, len(result.Pages))
|
||||
```
|
||||
|
||||
将 `Model` 设为 `paddleocr.PPOCRv6`(或 `"PP-OCRv6"`)可使用 PP-OCRv6 云端 OCR 模型。
|
||||
将 `Model` 设为 `paddleocr.PPOCRv5Latin`(或 `"PP-OCRv5-latin"`)可使用 PP-OCRv5 拉丁语系云端 OCR 模型。
|
||||
|
||||
文档解析默认使用 PaddleOCR-VL-1.6:
|
||||
|
||||
```go
|
||||
doc, err := client.ParseDocument(ctx, &paddleocr.DocParsingRequest{
|
||||
FilePath: "./report.pdf",
|
||||
Options: &paddleocr.PaddleOCRVLOptions{
|
||||
UseChartRecognition: paddleocr.Bool(true),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(doc.JobID, len(doc.Pages))
|
||||
```
|
||||
|
||||
## 构建与测试
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go test -race ./...
|
||||
```
|
||||
|
||||
公开发布前建议运行 `go test -race ./...`。
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package paddleocr
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
token string
|
||||
baseURL string
|
||||
jobsURL string
|
||||
requestTimeout time.Duration
|
||||
pollTimeout time.Duration
|
||||
clientPlatform string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(opts ...ClientOption) (*Client, error) {
|
||||
c := &Client{
|
||||
requestTimeout: 5 * time.Minute,
|
||||
pollTimeout: 10 * time.Minute,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
if c.token == "" {
|
||||
c.token = os.Getenv("PADDLEOCR_ACCESS_TOKEN")
|
||||
}
|
||||
if c.token == "" {
|
||||
return nil, &AuthError{PaddleOCRAPIError{Message: "Token is required. Set PADDLEOCR_ACCESS_TOKEN or use WithToken()."}}
|
||||
}
|
||||
if c.baseURL == "" {
|
||||
c.baseURL = os.Getenv("PADDLEOCR_BASE_URL")
|
||||
}
|
||||
if c.baseURL == "" {
|
||||
c.baseURL = DefaultBaseURL
|
||||
}
|
||||
c.baseURL = strings.TrimRight(c.baseURL, "/")
|
||||
c.jobsURL = c.baseURL + apiPath
|
||||
if c.httpClient == nil {
|
||||
c.httpClient = &http.Client{Timeout: c.requestTimeout}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Client) setClientPlatformHeader(req *http.Request) {
|
||||
if c.clientPlatform != "" {
|
||||
req.Header.Set("Client-Platform", c.clientPlatform)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package paddleocr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientPlatformHeader(t *testing.T) {
|
||||
var got string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = r.Header.Get("Client-Platform")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]string{"jobId": "job-1"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(
|
||||
WithToken("token"),
|
||||
WithBaseURL(server.URL),
|
||||
WithClientPlatform("my-app"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
job, err := client.SubmitOCR(context.Background(), &OCRRequest{
|
||||
FileURL: "https://example.test/input.pdf",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitOCR() error = %v", err)
|
||||
}
|
||||
if job.JobID != "job-1" {
|
||||
t.Fatalf("JobID = %q, want job-1", job.JobID)
|
||||
}
|
||||
if got != "my-app" {
|
||||
t.Fatalf("Client-Platform = %q, want my-app", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentParsingOptionsIncludeCurrentAndFutureServiceParameters(t *testing.T) {
|
||||
trueValue := true
|
||||
falseValue := false
|
||||
var got map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
OptionalPayload map[string]interface{} `json:"optionalPayload"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("Decode request body error = %v", err)
|
||||
}
|
||||
got = body.OptionalPayload
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]string{"jobId": "job-doc"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(
|
||||
WithToken("token"),
|
||||
WithBaseURL(server.URL),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = client.SubmitDocumentParsing(context.Background(), &DocParsingRequest{
|
||||
Model: PaddleOCRVL16,
|
||||
FileURL: "https://example.test/doc.pdf",
|
||||
Options: &PaddleOCRVLOptions{
|
||||
UseOcrForImageBlock: &trueValue,
|
||||
FormatBlockContent: &trueValue,
|
||||
MarkdownIgnoreLabels: []string{"image"},
|
||||
VlmExtraArgs: map[string]interface{}{"temperature": 0.1},
|
||||
ReturnMarkdownImages: &falseValue,
|
||||
OutputFormats: []string{"docx"},
|
||||
ExtraOptions: map[string]interface{}{"futureOption": "enabled"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitDocumentParsing() error = %v", err)
|
||||
}
|
||||
|
||||
if got["useOcrForImageBlock"] != true || got["formatBlockContent"] != true {
|
||||
t.Fatalf("got boolean options %#v", got)
|
||||
}
|
||||
if got["returnMarkdownImages"] != false || got["futureOption"] != "enabled" {
|
||||
t.Fatalf("got passthrough options %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultParsersPreserveRawFieldsAndDataInfo(t *testing.T) {
|
||||
ocrLine := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"dataInfo": map[string]interface{}{"numPages": float64(1)},
|
||||
"ocrResults": []interface{}{
|
||||
map[string]interface{}{
|
||||
"prunedResult": map[string]interface{}{"text": "hello"},
|
||||
"ocrImage": "ocr.png",
|
||||
"docPreprocessingImage": "pre.png",
|
||||
"inputImage": "input.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
ocrResult, err := parseOCRResult("job-ocr", []map[string]interface{}{ocrLine})
|
||||
if err != nil {
|
||||
t.Fatalf("parseOCRResult() error = %v", err)
|
||||
}
|
||||
if ocrResult.DataInfo["numPages"] != float64(1) {
|
||||
t.Fatalf("OCR metadata not preserved: %#v", ocrResult)
|
||||
}
|
||||
if ocrResult.Pages[0].DocPreprocessingImageURL != "pre.png" || ocrResult.Pages[0].InputImageURL != "input.png" {
|
||||
t.Fatalf("OCR page image URLs not preserved: %#v", ocrResult.Pages[0])
|
||||
}
|
||||
if ocrResult.Pages[0].Raw["ocrImage"] != "ocr.png" {
|
||||
t.Fatalf("OCR raw page not preserved: %#v", ocrResult.Pages[0].Raw)
|
||||
}
|
||||
|
||||
docPage := map[string]interface{}{
|
||||
"prunedResult": map[string]interface{}{"blocks": []interface{}{map[string]interface{}{"label": "text"}}},
|
||||
"markdown": map[string]interface{}{"text": "hello", "images": map[string]interface{}{"figure.png": "figure-url"}, "isStart": true},
|
||||
"outputImages": map[string]interface{}{"page.png": "page-url"},
|
||||
"inputImage": "input.png",
|
||||
"exports": map[string]interface{}{"docx": "docx-url"},
|
||||
}
|
||||
docLine := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"dataInfo": map[string]interface{}{"numPages": float64(1)},
|
||||
"layoutParsingResults": []interface{}{docPage},
|
||||
},
|
||||
}
|
||||
docResult, err := parseDocParsingResult("job-doc", []map[string]interface{}{docLine})
|
||||
if err != nil {
|
||||
t.Fatalf("parseDocParsingResult() error = %v", err)
|
||||
}
|
||||
if docResult.DataInfo["numPages"] != float64(1) {
|
||||
t.Fatalf("document metadata not preserved: %#v", docResult)
|
||||
}
|
||||
if docResult.Pages[0].PrunedResult == nil || docResult.Pages[0].Raw["inputImage"] != "input.png" {
|
||||
t.Fatalf("document page raw fields not preserved: %#v", docResult.Pages[0])
|
||||
}
|
||||
if docResult.Pages[0].Exports["docx"] != "docx-url" || docResult.Pages[0].Markdown["isStart"] != true {
|
||||
t.Fatalf("document structured fields not preserved: %#v", docResult.Pages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitOCRAcceptsOfficialModelNameString(t *testing.T) {
|
||||
var got string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("Decode request body error = %v", err)
|
||||
}
|
||||
got = body.Model
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]string{"jobId": "job-1"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(
|
||||
WithToken("token"),
|
||||
WithBaseURL(server.URL),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
job, err := client.SubmitOCR(context.Background(), &OCRRequest{
|
||||
Model: "PP-OCRv5",
|
||||
FileURL: "https://example.test/input.pdf",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitOCR() error = %v", err)
|
||||
}
|
||||
if job.Model != "PP-OCRv5" {
|
||||
t.Fatalf("Job model = %q, want PP-OCRv5", job.Model)
|
||||
}
|
||||
if got != "PP-OCRv5" {
|
||||
t.Fatalf("Request model = %q, want PP-OCRv5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitOCRAcceptsPPOCRv5LatinModelNameString(t *testing.T) {
|
||||
var got string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("Decode request body error = %v", err)
|
||||
}
|
||||
got = body.Model
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]string{"jobId": "job-latin"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewClient(
|
||||
WithToken("token"),
|
||||
WithBaseURL(server.URL),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
job, err := client.SubmitOCR(context.Background(), &OCRRequest{
|
||||
Model: PPOCRv5Latin,
|
||||
FileURL: "https://example.test/latin.pdf",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitOCR() error = %v", err)
|
||||
}
|
||||
if job.Model != PPOCRv5Latin {
|
||||
t.Fatalf("Job model = %q, want %s", job.Model, PPOCRv5Latin)
|
||||
}
|
||||
if got != PPOCRv5Latin {
|
||||
t.Fatalf("Request model = %q, want %s", got, PPOCRv5Latin)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package paddleocr provides a Go client for the PaddleOCR official API.
|
||||
//
|
||||
// Create a client with NewClient and authenticate with WithToken or the
|
||||
// PADDLEOCR_ACCESS_TOKEN environment variable. Use OCR for supported OCR models
|
||||
// and ParseDocument for document parsing models.
|
||||
// SubmitOCR and SubmitDocumentParsing return an Operation for non-blocking
|
||||
// status checks with Poll or typed waits with WaitOCR and WaitDocumentParsing.
|
||||
// SaveResource downloads one result resource URL. SaveOCRResultResources and
|
||||
// SaveDocumentParsingResultResources save resources from typed result objects
|
||||
// into an existing directory.
|
||||
//
|
||||
// Request timeout and polling timeout are configured separately with
|
||||
// WithRequestTimeout and WithPollTimeout. Errors are exposed as typed values,
|
||||
// such as AuthError, InvalidRequestError, APIError, ResponseFormatError, and
|
||||
// ResultParseError, and are suitable for errors.As.
|
||||
package paddleocr
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package paddleocr
|
||||
|
||||
import "fmt"
|
||||
|
||||
type PaddleOCRAPIError struct {
|
||||
Message string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *PaddleOCRAPIError) Error() string {
|
||||
if e.Message == "" && e.Cause != nil {
|
||||
return e.Cause.Error()
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func (e *PaddleOCRAPIError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
type AuthError struct {
|
||||
PaddleOCRAPIError
|
||||
}
|
||||
|
||||
type InvalidRequestError struct {
|
||||
PaddleOCRAPIError
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
PaddleOCRAPIError
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message)
|
||||
}
|
||||
|
||||
type RateLimitError struct {
|
||||
APIError
|
||||
}
|
||||
|
||||
type ServiceUnavailableError struct {
|
||||
APIError
|
||||
}
|
||||
|
||||
type JobFailedError struct {
|
||||
JobID string
|
||||
ErrorMsg string
|
||||
PaddleOCRAPIError
|
||||
}
|
||||
|
||||
func (e *JobFailedError) Error() string {
|
||||
return fmt.Sprintf("Job %s failed: %s", e.JobID, e.ErrorMsg)
|
||||
}
|
||||
|
||||
type RequestTimeoutError struct {
|
||||
PaddleOCRAPIError
|
||||
}
|
||||
|
||||
type PollTimeoutError struct {
|
||||
JobID string
|
||||
Elapsed float64
|
||||
PaddleOCRAPIError
|
||||
}
|
||||
|
||||
func (e *PollTimeoutError) Error() string {
|
||||
return fmt.Sprintf("Timed out after %.1fs waiting for job %s", e.Elapsed, e.JobID)
|
||||
}
|
||||
|
||||
type NetworkError struct {
|
||||
PaddleOCRAPIError
|
||||
}
|
||||
|
||||
type FileNotFoundError struct {
|
||||
Path string
|
||||
PaddleOCRAPIError
|
||||
}
|
||||
|
||||
func (e *FileNotFoundError) Error() string {
|
||||
return fmt.Sprintf("File not found: %s", e.Path)
|
||||
}
|
||||
|
||||
type ResponseFormatError struct {
|
||||
PaddleOCRAPIError
|
||||
}
|
||||
|
||||
type ResultParseError struct {
|
||||
PaddleOCRAPIError
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
paddleocr "github.com/PaddlePaddle/PaddleOCR/api_sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
client, err := paddleocr.NewClient()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// Convenience method (blocks until done)
|
||||
result, err := client.ParseDocument(ctx, &paddleocr.DocParsingRequest{
|
||||
Model: paddleocr.PPStructureV3,
|
||||
FilePath: "./sample.pdf",
|
||||
Options: &paddleocr.PPStructureV3Options{UseChartRecognition: paddleocr.Bool(true)},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for i, page := range result.Pages {
|
||||
fmt.Printf("Page %d:\n%s\n", i+1, page.MarkdownText)
|
||||
}
|
||||
|
||||
// Manual control with typed job metadata and typed wait methods.
|
||||
ocrJob, _ := client.SubmitOCR(ctx, &paddleocr.OCRRequest{FileURL: "https://example.com/f1.pdf"})
|
||||
docJob, _ := client.SubmitDocumentParsing(ctx, &paddleocr.DocParsingRequest{
|
||||
Model: paddleocr.PPStructureV3, FilePath: "./sample.pdf",
|
||||
})
|
||||
|
||||
ocrResult, err := client.WaitOCRResult(ctx, ocrJob.JobID)
|
||||
if err != nil {
|
||||
log.Printf("OCR job error: %v", err)
|
||||
}
|
||||
docResult, err := client.WaitDocumentParsingResult(ctx, docJob.JobID)
|
||||
if err != nil {
|
||||
log.Printf("document parsing job error: %v", err)
|
||||
}
|
||||
fmt.Printf("OCR done: %v\n", ocrResult)
|
||||
fmt.Printf("Document parsing done: %v\n", docResult)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
paddleocr "github.com/PaddlePaddle/PaddleOCR/api_sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
client, err := paddleocr.NewClient()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := client.OCR(ctx, &paddleocr.OCRRequest{
|
||||
FileURL: "https://example.com/invoice.pdf",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for i, page := range result.Pages {
|
||||
fmt.Printf("Page %d: %v\n", i+1, page.PrunedResult)
|
||||
fmt.Printf(" Image URL: %s\n", page.OCRImageURL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/PaddlePaddle/PaddleOCR/api_sdk/go
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package paddleocr
|
||||
|
||||
const (
|
||||
PPOCRv5 = "PP-OCRv5"
|
||||
PPOCRv5Latin = "PP-OCRv5-latin"
|
||||
PPOCRv6 = "PP-OCRv6"
|
||||
PPStructureV3 = "PP-StructureV3"
|
||||
PaddleOCRVL = "PaddleOCR-VL"
|
||||
PaddleOCRVL15 = "PaddleOCR-VL-1.5"
|
||||
PaddleOCRVL16 = "PaddleOCR-VL-1.6"
|
||||
)
|
||||
|
||||
// IsOCRModel reports whether model is supported by OCR APIs.
|
||||
func IsOCRModel(model string) bool {
|
||||
return model == PPOCRv5 || model == PPOCRv5Latin || model == PPOCRv6
|
||||
}
|
||||
|
||||
// IsDocumentParsingModel reports whether model is supported by document parsing APIs.
|
||||
func IsDocumentParsingModel(model string) bool {
|
||||
switch model {
|
||||
case PPStructureV3, PaddleOCRVL, PaddleOCRVL15, PaddleOCRVL16:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsVLModel reports whether model is a PaddleOCR-VL family model.
|
||||
func IsVLModel(model string) bool {
|
||||
switch model {
|
||||
case PaddleOCRVL, PaddleOCRVL15, PaddleOCRVL16:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type OCROptions struct {
|
||||
UseDocOrientationClassify *bool `json:"useDocOrientationClassify,omitempty"`
|
||||
UseDocUnwarping *bool `json:"useDocUnwarping,omitempty"`
|
||||
UseTextlineOrientation *bool `json:"useTextlineOrientation,omitempty"`
|
||||
TextDetLimitSideLen *int `json:"textDetLimitSideLen,omitempty"`
|
||||
TextDetLimitType *string `json:"textDetLimitType,omitempty"`
|
||||
TextDetThresh *float64 `json:"textDetThresh,omitempty"`
|
||||
TextDetBoxThresh *float64 `json:"textDetBoxThresh,omitempty"`
|
||||
TextDetUnclipRatio *float64 `json:"textDetUnclipRatio,omitempty"`
|
||||
TextRecScoreThresh *float64 `json:"textRecScoreThresh,omitempty"`
|
||||
Visualize *bool `json:"visualize,omitempty"`
|
||||
ExtraOptions map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
type PPStructureV3Options struct {
|
||||
UseDocOrientationClassify *bool `json:"useDocOrientationClassify,omitempty"`
|
||||
UseDocUnwarping *bool `json:"useDocUnwarping,omitempty"`
|
||||
UseTextlineOrientation *bool `json:"useTextlineOrientation,omitempty"`
|
||||
UseSealRecognition *bool `json:"useSealRecognition,omitempty"`
|
||||
UseTableRecognition *bool `json:"useTableRecognition,omitempty"`
|
||||
UseFormulaRecognition *bool `json:"useFormulaRecognition,omitempty"`
|
||||
UseChartRecognition *bool `json:"useChartRecognition,omitempty"`
|
||||
UseRegionDetection *bool `json:"useRegionDetection,omitempty"`
|
||||
LayoutThreshold interface{} `json:"layoutThreshold,omitempty"`
|
||||
LayoutNms *bool `json:"layoutNms,omitempty"`
|
||||
LayoutUnclipRatio interface{} `json:"layoutUnclipRatio,omitempty"`
|
||||
LayoutMergeBboxesMode interface{} `json:"layoutMergeBboxesMode,omitempty"`
|
||||
FormatBlockContent *bool `json:"formatBlockContent,omitempty"`
|
||||
TextDetLimitSideLen *int `json:"textDetLimitSideLen,omitempty"`
|
||||
TextDetLimitType *string `json:"textDetLimitType,omitempty"`
|
||||
TextDetThresh *float64 `json:"textDetThresh,omitempty"`
|
||||
TextDetBoxThresh *float64 `json:"textDetBoxThresh,omitempty"`
|
||||
TextDetUnclipRatio *float64 `json:"textDetUnclipRatio,omitempty"`
|
||||
TextRecScoreThresh *float64 `json:"textRecScoreThresh,omitempty"`
|
||||
UseWiredTableCellsTransToHtml *bool `json:"useWiredTableCellsTransToHtml,omitempty"`
|
||||
UseWirelessTableCellsTransToHtml *bool `json:"useWirelessTableCellsTransToHtml,omitempty"`
|
||||
UseTableOrientationClassify *bool `json:"useTableOrientationClassify,omitempty"`
|
||||
UseOcrResultsWithTableCells *bool `json:"useOcrResultsWithTableCells,omitempty"`
|
||||
UseE2eWiredTableRecModel *bool `json:"useE2eWiredTableRecModel,omitempty"`
|
||||
UseE2eWirelessTableRecModel *bool `json:"useE2eWirelessTableRecModel,omitempty"`
|
||||
MarkdownIgnoreLabels []string `json:"markdownIgnoreLabels,omitempty"`
|
||||
PrettifyMarkdown *bool `json:"prettifyMarkdown,omitempty"`
|
||||
ShowFormulaNumber *bool `json:"showFormulaNumber,omitempty"`
|
||||
ReturnMarkdownImages *bool `json:"returnMarkdownImages,omitempty"`
|
||||
OutputFormats []string `json:"outputFormats,omitempty"`
|
||||
Visualize *bool `json:"visualize,omitempty"`
|
||||
ExtraOptions map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
type PaddleOCRVLOptions struct {
|
||||
UseDocOrientationClassify *bool `json:"useDocOrientationClassify,omitempty"`
|
||||
UseDocUnwarping *bool `json:"useDocUnwarping,omitempty"`
|
||||
UseLayoutDetection *bool `json:"useLayoutDetection,omitempty"`
|
||||
UseChartRecognition *bool `json:"useChartRecognition,omitempty"`
|
||||
UseSealRecognition *bool `json:"useSealRecognition,omitempty"`
|
||||
UseOcrForImageBlock *bool `json:"useOcrForImageBlock,omitempty"`
|
||||
LayoutThreshold interface{} `json:"layoutThreshold,omitempty"`
|
||||
LayoutNms *bool `json:"layoutNms,omitempty"`
|
||||
LayoutUnclipRatio interface{} `json:"layoutUnclipRatio,omitempty"`
|
||||
LayoutMergeBboxesMode interface{} `json:"layoutMergeBboxesMode,omitempty"`
|
||||
LayoutShapeMode *string `json:"layoutShapeMode,omitempty"`
|
||||
PromptLabel *string `json:"promptLabel,omitempty"`
|
||||
FormatBlockContent *bool `json:"formatBlockContent,omitempty"`
|
||||
RepetitionPenalty *float64 `json:"repetitionPenalty,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"topP,omitempty"`
|
||||
MinPixels *int `json:"minPixels,omitempty"`
|
||||
MaxPixels *int `json:"maxPixels,omitempty"`
|
||||
MaxNewTokens *int `json:"maxNewTokens,omitempty"`
|
||||
VlmExtraArgs map[string]interface{} `json:"vlmExtraArgs,omitempty"`
|
||||
MergeLayoutBlocks *bool `json:"mergeLayoutBlocks,omitempty"`
|
||||
MarkdownIgnoreLabels []string `json:"markdownIgnoreLabels,omitempty"`
|
||||
PrettifyMarkdown *bool `json:"prettifyMarkdown,omitempty"`
|
||||
ShowFormulaNumber *bool `json:"showFormulaNumber,omitempty"`
|
||||
RestructurePages *bool `json:"restructurePages,omitempty"`
|
||||
MergeTables *bool `json:"mergeTables,omitempty"`
|
||||
RelevelTitles *bool `json:"relevelTitles,omitempty"`
|
||||
ReturnMarkdownImages *bool `json:"returnMarkdownImages,omitempty"`
|
||||
OutputFormats []string `json:"outputFormats,omitempty"`
|
||||
Visualize *bool `json:"visualize,omitempty"`
|
||||
ExtraOptions map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
// DocParsingOptionsProvider marks document parsing option structs.
|
||||
type DocParsingOptionsProvider interface {
|
||||
isDocParsingOptions()
|
||||
}
|
||||
|
||||
func (*PPStructureV3Options) isDocParsingOptions() {}
|
||||
func (*PaddleOCRVLOptions) isDocParsingOptions() {}
|
||||
|
||||
type OCRRequest struct {
|
||||
Model string
|
||||
FileURL string
|
||||
FilePath string
|
||||
PageRanges string
|
||||
BatchID string
|
||||
Options *OCROptions
|
||||
}
|
||||
|
||||
type DocParsingRequest struct {
|
||||
Model string
|
||||
FileURL string
|
||||
FilePath string
|
||||
PageRanges string
|
||||
BatchID string
|
||||
Options DocParsingOptionsProvider
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package paddleocr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
func (c *Client) OCR(ctx context.Context, req *OCRRequest) (*OCRResult, error) {
|
||||
job, err := c.SubmitOCR(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.WaitOCRResult(ctx, job.JobID)
|
||||
}
|
||||
|
||||
// ParseDocument performs document parsing. Blocks until result is ready.
|
||||
func (c *Client) ParseDocument(ctx context.Context, req *DocParsingRequest) (*DocParsingResult, error) {
|
||||
job, err := c.SubmitDocumentParsing(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.WaitDocumentParsingResult(ctx, job.JobID)
|
||||
}
|
||||
|
||||
// SubmitOCR submits an OCR job and returns job metadata for tracking.
|
||||
func (c *Client) SubmitOCR(ctx context.Context, req *OCRRequest) (*Job, error) {
|
||||
if req == nil {
|
||||
return nil, &InvalidRequestError{PaddleOCRAPIError{Message: "OCR request is nil"}}
|
||||
}
|
||||
model := req.Model
|
||||
if model == "" {
|
||||
model = PPOCRv6
|
||||
}
|
||||
if !IsOCRModel(model) {
|
||||
return nil, &InvalidRequestError{PaddleOCRAPIError{Message: "model is not an OCR model: " + model}}
|
||||
}
|
||||
jobID, err := c.submit(ctx, model, req.FileURL, req.FilePath, req.Options, req.PageRanges, req.BatchID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Job{JobID: jobID, Model: model, Task: "ocr", PageRanges: req.PageRanges, BatchID: req.BatchID}, nil
|
||||
}
|
||||
|
||||
// SubmitDocumentParsing submits a document parsing job and returns job metadata for tracking.
|
||||
func (c *Client) SubmitDocumentParsing(ctx context.Context, req *DocParsingRequest) (*Job, error) {
|
||||
if req == nil {
|
||||
return nil, &InvalidRequestError{PaddleOCRAPIError{Message: "document parsing request is nil"}}
|
||||
}
|
||||
model := req.Model
|
||||
if model == "" {
|
||||
model = PaddleOCRVL16
|
||||
}
|
||||
if !IsDocumentParsingModel(model) {
|
||||
return nil, &InvalidRequestError{PaddleOCRAPIError{Message: "model is not a document parsing model: " + model}}
|
||||
}
|
||||
jobID, err := c.submit(ctx, model, req.FileURL, req.FilePath, req.Options, req.PageRanges, req.BatchID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Job{JobID: jobID, Model: model, Task: "document_parsing", PageRanges: req.PageRanges, BatchID: req.BatchID}, nil
|
||||
}
|
||||
|
||||
func (c *Client) WaitOCRResult(ctx context.Context, jobID string) (*OCRResult, error) {
|
||||
jsonlData, err := c.pollUntilDone(ctx, jobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseOCRResult(jobID, jsonlData)
|
||||
}
|
||||
|
||||
func (c *Client) WaitDocumentParsingResult(ctx context.Context, jobID string) (*DocParsingResult, error) {
|
||||
jsonlData, err := c.pollUntilDone(ctx, jobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseDocParsingResult(jobID, jsonlData)
|
||||
}
|
||||
|
||||
func (c *Client) GetStatus(ctx context.Context, jobID string) (*JobStatus, error) {
|
||||
status, err := c.getJobStatus(ctx, jobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizeStatus(jobID, status)
|
||||
}
|
||||
|
||||
func (c *Client) GetBatchStatus(ctx context.Context, batchID string) (*BatchStatus, error) {
|
||||
if batchID == "" {
|
||||
return nil, &InvalidRequestError{PaddleOCRAPIError{Message: "batchID is required"}}
|
||||
}
|
||||
return c.getBatchStatus(ctx, batchID)
|
||||
}
|
||||
|
||||
func (c *Client) submit(ctx context.Context, model, fileURL, filePath string, options interface{}, pageRanges, batchID string) (string, error) {
|
||||
if fileURL == "" && filePath == "" {
|
||||
return "", &InvalidRequestError{PaddleOCRAPIError{Message: "Either FileURL or FilePath is required."}}
|
||||
}
|
||||
if fileURL != "" && filePath != "" {
|
||||
return "", &InvalidRequestError{PaddleOCRAPIError{Message: "FileURL and FilePath are mutually exclusive."}}
|
||||
}
|
||||
|
||||
payload := defaultPayload(model, options)
|
||||
|
||||
if fileURL != "" {
|
||||
return c.submitURL(ctx, model, fileURL, payload, pageRanges, batchID)
|
||||
}
|
||||
return c.submitFile(ctx, model, filePath, payload, pageRanges, batchID)
|
||||
}
|
||||
|
||||
func defaultPayload(model string, options interface{}) interface{} {
|
||||
switch typed := options.(type) {
|
||||
case *OCROptions:
|
||||
if typed != nil {
|
||||
return payloadWithExtraOptions(typed)
|
||||
}
|
||||
case *PPStructureV3Options:
|
||||
if typed != nil {
|
||||
return payloadWithExtraOptions(typed)
|
||||
}
|
||||
case *PaddleOCRVLOptions:
|
||||
if typed != nil {
|
||||
return payloadWithExtraOptions(typed)
|
||||
}
|
||||
default:
|
||||
if options != nil {
|
||||
return payloadWithExtraOptions(options)
|
||||
}
|
||||
}
|
||||
if IsOCRModel(model) {
|
||||
return payloadWithExtraOptions(&OCROptions{})
|
||||
}
|
||||
if IsVLModel(model) {
|
||||
return payloadWithExtraOptions(&PaddleOCRVLOptions{})
|
||||
}
|
||||
return payloadWithExtraOptions(&PPStructureV3Options{})
|
||||
}
|
||||
|
||||
func payloadWithExtraOptions(options interface{}) interface{} {
|
||||
payloadBytes, _ := json.Marshal(options)
|
||||
payload := map[string]interface{}{}
|
||||
_ = json.Unmarshal(payloadBytes, &payload)
|
||||
|
||||
var extraOptions map[string]interface{}
|
||||
switch typed := options.(type) {
|
||||
case *OCROptions:
|
||||
extraOptions = typed.ExtraOptions
|
||||
case *PPStructureV3Options:
|
||||
extraOptions = typed.ExtraOptions
|
||||
case *PaddleOCRVLOptions:
|
||||
extraOptions = typed.ExtraOptions
|
||||
}
|
||||
for key, value := range extraOptions {
|
||||
payload[key] = value
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func parseOCRResult(jobID string, jsonlData []map[string]interface{}) (*OCRResult, error) {
|
||||
result := &OCRResult{JobID: jobID, DataInfo: map[string]interface{}{}}
|
||||
for _, lineObj := range jsonlData {
|
||||
resultData, ok := lineObj["result"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, &ResultParseError{PaddleOCRAPIError{Message: "OCR result item is missing result"}}
|
||||
}
|
||||
if dataInfo, ok := resultData["dataInfo"].(map[string]interface{}); ok {
|
||||
for key, value := range dataInfo {
|
||||
result.DataInfo[key] = value
|
||||
}
|
||||
}
|
||||
ocrResults, ok := resultData["ocrResults"].([]interface{})
|
||||
if !ok {
|
||||
return nil, &ResultParseError{PaddleOCRAPIError{Message: "OCR result item is missing ocrResults"}}
|
||||
}
|
||||
for _, item := range ocrResults {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, &ResultParseError{PaddleOCRAPIError{Message: "OCR result page is malformed"}}
|
||||
}
|
||||
if _, ok := itemMap["prunedResult"]; !ok {
|
||||
return nil, &ResultParseError{PaddleOCRAPIError{Message: "OCR result page is missing prunedResult"}}
|
||||
}
|
||||
page := OCRPage{
|
||||
PrunedResult: itemMap["prunedResult"],
|
||||
OCRImageURL: getString(itemMap, "ocrImage"),
|
||||
DocPreprocessingImageURL: getString(itemMap, "docPreprocessingImage"),
|
||||
InputImageURL: getString(itemMap, "inputImage"),
|
||||
Raw: itemMap,
|
||||
}
|
||||
result.Pages = append(result.Pages, page)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseDocParsingResult(jobID string, jsonlData []map[string]interface{}) (*DocParsingResult, error) {
|
||||
result := &DocParsingResult{JobID: jobID, DataInfo: map[string]interface{}{}}
|
||||
for _, lineObj := range jsonlData {
|
||||
resultData, ok := lineObj["result"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, &ResultParseError{PaddleOCRAPIError{Message: "document parsing result item is missing result"}}
|
||||
}
|
||||
if dataInfo, ok := resultData["dataInfo"].(map[string]interface{}); ok {
|
||||
for key, value := range dataInfo {
|
||||
result.DataInfo[key] = value
|
||||
}
|
||||
}
|
||||
lpResults, ok := resultData["layoutParsingResults"].([]interface{})
|
||||
if !ok {
|
||||
return nil, &ResultParseError{PaddleOCRAPIError{Message: "document parsing result item is missing layoutParsingResults"}}
|
||||
}
|
||||
for _, item := range lpResults {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, &ResultParseError{PaddleOCRAPIError{Message: "document parsing result page is malformed"}}
|
||||
}
|
||||
markdown, ok := itemMap["markdown"].(map[string]interface{})
|
||||
if !ok || getString(markdown, "text") == "" {
|
||||
return nil, &ResultParseError{PaddleOCRAPIError{Message: "document parsing result page is missing markdown.text"}}
|
||||
}
|
||||
page := DocParsingPage{
|
||||
MarkdownText: getString(markdown, "text"),
|
||||
MarkdownImages: getStringMap(markdown, "images"),
|
||||
OutputImages: getStringMap(itemMap, "outputImages"),
|
||||
PrunedResult: itemMap["prunedResult"],
|
||||
InputImageURL: getString(itemMap, "inputImage"),
|
||||
Exports: getMap(itemMap, "exports"),
|
||||
Markdown: markdown,
|
||||
Raw: itemMap,
|
||||
}
|
||||
result.Pages = append(result.Pages, page)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
v, _ := m[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func getMap(m map[string]interface{}, key string) map[string]interface{} {
|
||||
value, ok := m[key].(map[string]interface{})
|
||||
if !ok {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func getStringMap(m map[string]interface{}, key string) map[string]string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := m[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case map[string]interface{}:
|
||||
result := make(map[string]string, len(v))
|
||||
for k, val := range v {
|
||||
if s, ok := val.(string); ok {
|
||||
result[k] = s
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
b, _ := json.Marshal(raw)
|
||||
var result map[string]string
|
||||
json.Unmarshal(b, &result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStatus(jobID string, status *jobStatusResponse) (*JobStatus, error) {
|
||||
if status == nil || status.State == "" {
|
||||
return nil, &ResponseFormatError{PaddleOCRAPIError{Message: "status response is missing state"}}
|
||||
}
|
||||
if status.State != "pending" && status.State != "running" && status.State != "done" && status.State != "failed" {
|
||||
return nil, &ResponseFormatError{PaddleOCRAPIError{Message: "unknown job state: " + status.State}}
|
||||
}
|
||||
result := &JobStatus{
|
||||
JobID: jobID,
|
||||
State: status.State,
|
||||
ResultURL: status.ResultURL,
|
||||
ErrorMsg: status.ErrorMsg,
|
||||
}
|
||||
if len(status.ExtractProgress) != 0 && string(status.ExtractProgress) != "null" {
|
||||
var progress extractProgress
|
||||
if err := json.Unmarshal(status.ExtractProgress, &progress); err != nil {
|
||||
return nil, &ResponseFormatError{PaddleOCRAPIError{Message: "status progress is malformed", Cause: err}}
|
||||
}
|
||||
result.Progress = &Progress{
|
||||
TotalPages: progress.TotalPages,
|
||||
ExtractedPages: progress.ExtractedPages,
|
||||
StartTime: progress.StartTime,
|
||||
EndTime: progress.EndTime,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package paddleocr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// Operation represents an in-progress job. Use Wait() to block until done,
|
||||
// or Poll() to check status without blocking.
|
||||
type Operation struct {
|
||||
client *Client
|
||||
JobID string
|
||||
model string
|
||||
}
|
||||
|
||||
// Wait blocks until the job completes and returns the parsed result.
|
||||
func (op *Operation) Wait(ctx context.Context) (interface{}, error) {
|
||||
jsonlData, err := op.client.pollUntilDone(ctx, op.JobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if IsOCRModel(op.model) {
|
||||
return parseOCRResult(op.JobID, jsonlData)
|
||||
}
|
||||
return parseDocParsingResult(op.JobID, jsonlData)
|
||||
}
|
||||
|
||||
// Poll checks the current job status without waiting.
|
||||
// Returns the status, whether the job is done, and any error.
|
||||
func (op *Operation) Poll(ctx context.Context) (*JobStatus, bool, error) {
|
||||
status, err := op.client.getJobStatus(ctx, op.JobID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
js := &JobStatus{
|
||||
JobID: op.JobID,
|
||||
State: status.State,
|
||||
ErrorMsg: status.ErrorMsg,
|
||||
}
|
||||
|
||||
if status.ExtractProgress != nil {
|
||||
var ep extractProgress
|
||||
if err := json.Unmarshal(status.ExtractProgress, &ep); err == nil {
|
||||
js.Progress = &Progress{
|
||||
TotalPages: ep.TotalPages,
|
||||
ExtractedPages: ep.ExtractedPages,
|
||||
StartTime: ep.StartTime,
|
||||
EndTime: ep.EndTime,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return js, status.State == "done", nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package paddleocr
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultBaseURL = "https://paddleocr.aistudio-app.com"
|
||||
|
||||
const apiPath = "/api/v2/ocr/jobs"
|
||||
|
||||
type ClientOption func(*Client)
|
||||
|
||||
func WithToken(token string) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.token = token
|
||||
}
|
||||
}
|
||||
|
||||
func WithBaseURL(url string) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.baseURL = url
|
||||
}
|
||||
}
|
||||
|
||||
func WithTimeout(d time.Duration) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.requestTimeout = d
|
||||
c.pollTimeout = d
|
||||
}
|
||||
}
|
||||
|
||||
func WithRequestTimeout(d time.Duration) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.requestTimeout = d
|
||||
}
|
||||
}
|
||||
|
||||
func WithPollTimeout(d time.Duration) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.pollTimeout = d
|
||||
}
|
||||
}
|
||||
|
||||
func WithClientPlatform(clientPlatform string) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.clientPlatform = clientPlatform
|
||||
}
|
||||
}
|
||||
|
||||
func WithHTTPClient(hc *http.Client) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.httpClient = hc
|
||||
}
|
||||
}
|
||||
|
||||
func Bool(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package paddleocr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
initialInterval = 3 * time.Second
|
||||
multiplier = 1.5
|
||||
maxInterval = 15 * time.Second
|
||||
)
|
||||
|
||||
func (c *Client) pollUntilDone(ctx context.Context, jobID string) ([]map[string]interface{}, error) {
|
||||
interval := initialInterval
|
||||
deadline := time.Now().Add(c.pollTimeout)
|
||||
pollCtx, cancel := context.WithDeadline(ctx, deadline)
|
||||
defer cancel()
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
if interval > remaining {
|
||||
interval = remaining
|
||||
}
|
||||
timer := time.NewTimer(interval)
|
||||
select {
|
||||
case <-pollCtx.Done():
|
||||
timer.Stop()
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, &PollTimeoutError{
|
||||
JobID: jobID,
|
||||
Elapsed: c.pollTimeout.Seconds(),
|
||||
PaddleOCRAPIError: PaddleOCRAPIError{Message: fmt.Sprintf("Timed out after %.1fs", c.pollTimeout.Seconds())},
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
status, err := c.getJobStatus(pollCtx, jobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch status.State {
|
||||
case "done":
|
||||
jsonURL := status.ResultURL["jsonUrl"]
|
||||
if jsonURL == "" {
|
||||
return nil, &ResponseFormatError{PaddleOCRAPIError{Message: "done job response is missing resultUrl.jsonUrl"}}
|
||||
}
|
||||
return c.fetchJSONL(pollCtx, jsonURL)
|
||||
case "failed":
|
||||
return nil, &JobFailedError{
|
||||
JobID: jobID,
|
||||
ErrorMsg: status.ErrorMsg,
|
||||
PaddleOCRAPIError: PaddleOCRAPIError{Message: status.ErrorMsg},
|
||||
}
|
||||
}
|
||||
|
||||
next := time.Duration(float64(interval) * multiplier)
|
||||
if next > maxInterval {
|
||||
next = maxInterval
|
||||
}
|
||||
interval = next
|
||||
}
|
||||
|
||||
return nil, &PollTimeoutError{
|
||||
JobID: jobID,
|
||||
Elapsed: c.pollTimeout.Seconds(),
|
||||
PaddleOCRAPIError: PaddleOCRAPIError{Message: fmt.Sprintf("Timed out after %.1fs", c.pollTimeout.Seconds())},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package paddleocr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SaveResourceOption func(*saveResourceOptions)
|
||||
|
||||
type saveResourceOptions struct {
|
||||
overwrite bool
|
||||
}
|
||||
|
||||
func WithOverwrite(overwrite bool) SaveResourceOption {
|
||||
return func(o *saveResourceOptions) {
|
||||
o.overwrite = overwrite
|
||||
}
|
||||
}
|
||||
|
||||
// SaveResource downloads one result resource URL.
|
||||
func (c *Client) SaveResource(ctx context.Context, resourceURL, dest string, opts ...SaveResourceOption) (string, error) {
|
||||
if resourceURL == "" {
|
||||
return "", &InvalidRequestError{PaddleOCRAPIError{Message: "resource URL is required"}}
|
||||
}
|
||||
if dest == "" {
|
||||
return "", &InvalidRequestError{PaddleOCRAPIError{Message: "destination path is required"}}
|
||||
}
|
||||
options := saveResourceOptions{}
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
savedPath, err := resolveSavePath(resourceURL, dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parent := filepath.Dir(savedPath)
|
||||
if st, err := os.Stat(parent); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", &FileNotFoundError{Path: parent, PaddleOCRAPIError: PaddleOCRAPIError{Message: "destination parent not found: " + parent, Cause: err}}
|
||||
}
|
||||
return "", err
|
||||
} else if !st.IsDir() {
|
||||
return "", &FileNotFoundError{Path: parent, PaddleOCRAPIError: PaddleOCRAPIError{Message: "destination parent is not a directory: " + parent}}
|
||||
}
|
||||
if !options.overwrite {
|
||||
if _, err := os.Stat(savedPath); err == nil {
|
||||
return "", &InvalidRequestError{PaddleOCRAPIError{Message: "destination already exists: " + savedPath}}
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil)
|
||||
if err != nil {
|
||||
return "", &NetworkError{PaddleOCRAPIError{Message: err.Error(), Cause: err}}
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", classifyHTTPError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if err := raiseForResourceResponse(resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(parent, "."+filepath.Base(savedPath)+".tmp-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
cleanupTemp := true
|
||||
defer func() {
|
||||
if cleanupTemp {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
||||
_ = tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if options.overwrite {
|
||||
if err := os.Rename(tmpPath, savedPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
if err := os.Link(tmpPath, savedPath); err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
return "", &InvalidRequestError{PaddleOCRAPIError{Message: "destination already exists: " + savedPath, Cause: err}}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if err := os.Remove(tmpPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
cleanupTemp = false
|
||||
return savedPath, nil
|
||||
}
|
||||
|
||||
func (c *Client) SaveOCRResultResources(ctx context.Context, result *OCRResult, destDir string, opts ...SaveResourceOption) ([]string, error) {
|
||||
if result == nil {
|
||||
return nil, &InvalidRequestError{PaddleOCRAPIError{Message: "OCR result is required"}}
|
||||
}
|
||||
if err := requireExistingDirectory(destDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
saved := make([]string, 0, len(result.Pages))
|
||||
for i, page := range result.Pages {
|
||||
if page.OCRImageURL == "" {
|
||||
continue
|
||||
}
|
||||
name := fmt.Sprintf("ocr-page-%d%s", i+1, safeResourceExtension(page.OCRImageURL))
|
||||
if err := validateResultResourceFilename(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path, err := c.SaveResource(ctx, page.OCRImageURL, filepath.Join(destDir, name), opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
saved = append(saved, path)
|
||||
}
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
func (c *Client) SaveDocumentParsingResultResources(ctx context.Context, result *DocParsingResult, destDir string, opts ...SaveResourceOption) ([]string, error) {
|
||||
if result == nil {
|
||||
return nil, &InvalidRequestError{PaddleOCRAPIError{Message: "document parsing result is required"}}
|
||||
}
|
||||
if err := requireExistingDirectory(destDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var saved []string
|
||||
for _, page := range result.Pages {
|
||||
paths, err := c.saveNamedResourceMap(ctx, page.MarkdownImages, destDir, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
saved = append(saved, paths...)
|
||||
paths, err = c.saveNamedResourceMap(ctx, page.OutputImages, destDir, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
saved = append(saved, paths...)
|
||||
}
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
func (c *Client) saveNamedResourceMap(ctx context.Context, resources map[string]string, destDir string, opts ...SaveResourceOption) ([]string, error) {
|
||||
keys := make([]string, 0, len(resources))
|
||||
for key := range resources {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
saved := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
resourceURL := resources[key]
|
||||
if resourceURL == "" {
|
||||
continue
|
||||
}
|
||||
name, err := resultResourceFilename(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path, err := c.SaveResource(ctx, resourceURL, filepath.Join(destDir, name), opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
saved = append(saved, path)
|
||||
}
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
func resolveSavePath(resourceURL, dest string) (string, error) {
|
||||
if st, err := os.Stat(dest); err == nil && st.IsDir() {
|
||||
name := "resource"
|
||||
u, parseErr := url.Parse(resourceURL)
|
||||
if parseErr == nil {
|
||||
if base := path.Base(u.Path); base != "." && base != "/" && base != "" {
|
||||
name = base
|
||||
}
|
||||
}
|
||||
return filepath.Join(dest, name), nil
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
func raiseForResourceResponse(resp *http.Response) error {
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return &APIError{StatusCode: resp.StatusCode, PaddleOCRAPIError: PaddleOCRAPIError{Message: string(body)}}
|
||||
}
|
||||
|
||||
func requireExistingDirectory(destDir string) error {
|
||||
if destDir == "" {
|
||||
return &InvalidRequestError{PaddleOCRAPIError{Message: "destination directory is required"}}
|
||||
}
|
||||
st, err := os.Stat(destDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return &FileNotFoundError{Path: destDir, PaddleOCRAPIError: PaddleOCRAPIError{Message: "destination directory not found: " + destDir, Cause: err}}
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !st.IsDir() {
|
||||
return &FileNotFoundError{Path: destDir, PaddleOCRAPIError: PaddleOCRAPIError{Message: "destination is not a directory: " + destDir}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resultResourceFilename(key string) (string, error) {
|
||||
if err := validateResultResourceFilename(key); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func validateResultResourceFilename(name string) error {
|
||||
if name == "" || name == "." || name == ".." || filepath.IsAbs(name) || path.IsAbs(name) || strings.ContainsAny(name, `/\`) {
|
||||
return &InvalidRequestError{PaddleOCRAPIError{Message: "unsafe resource filename: " + name}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeResourceExtension(resourceURL string) string {
|
||||
u, err := url.Parse(resourceURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
ext := path.Ext(path.Base(u.Path))
|
||||
if len(ext) <= 1 {
|
||||
return ""
|
||||
}
|
||||
if err := validateResultResourceFilename("resource" + ext); err != nil {
|
||||
return ""
|
||||
}
|
||||
return ext
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package paddleocr
|
||||
|
||||
type OCRPage struct {
|
||||
PrunedResult interface{} `json:"prunedResult"`
|
||||
OCRImageURL string `json:"ocrImageUrl,omitempty"`
|
||||
DocPreprocessingImageURL string `json:"docPreprocessingImageUrl,omitempty"`
|
||||
InputImageURL string `json:"inputImageUrl,omitempty"`
|
||||
Raw map[string]interface{} `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
type DocParsingPage struct {
|
||||
MarkdownText string `json:"markdownText"`
|
||||
MarkdownImages map[string]string `json:"markdownImages"`
|
||||
OutputImages map[string]string `json:"outputImages"`
|
||||
PrunedResult interface{} `json:"prunedResult,omitempty"`
|
||||
InputImageURL string `json:"inputImageUrl,omitempty"`
|
||||
Exports map[string]interface{} `json:"exports,omitempty"`
|
||||
Markdown map[string]interface{} `json:"markdown,omitempty"`
|
||||
Raw map[string]interface{} `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
type OCRResult struct {
|
||||
JobID string `json:"jobId"`
|
||||
Pages []OCRPage `json:"pages"`
|
||||
DataInfo map[string]interface{} `json:"dataInfo,omitempty"`
|
||||
}
|
||||
|
||||
type DocParsingResult struct {
|
||||
JobID string `json:"jobId"`
|
||||
Pages []DocParsingPage `json:"pages"`
|
||||
DataInfo map[string]interface{} `json:"dataInfo,omitempty"`
|
||||
}
|
||||
|
||||
type Progress struct {
|
||||
TotalPages int `json:"totalPages"`
|
||||
ExtractedPages int `json:"extractedPages"`
|
||||
StartTime string `json:"startTime,omitempty"`
|
||||
EndTime string `json:"endTime,omitempty"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
JobID string `json:"jobId"`
|
||||
Model string `json:"model"`
|
||||
Task string `json:"task"`
|
||||
PageRanges string `json:"pageRanges,omitempty"`
|
||||
BatchID string `json:"batchId,omitempty"`
|
||||
}
|
||||
|
||||
type JobStatus struct {
|
||||
JobID string `json:"jobId"`
|
||||
State string `json:"state"`
|
||||
Progress *Progress `json:"progress,omitempty"`
|
||||
ResultURL map[string]string `json:"resultUrl,omitempty"`
|
||||
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||
}
|
||||
|
||||
type BatchStatus struct {
|
||||
BatchID string `json:"batchId"`
|
||||
Jobs []*JobStatus `json:"jobs"`
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package paddleocr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type apiResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type submitResponse struct {
|
||||
JobID string `json:"jobId"`
|
||||
}
|
||||
|
||||
type jobStatusResponse struct {
|
||||
JobID string `json:"jobId"`
|
||||
State string `json:"state"`
|
||||
ExtractProgress json.RawMessage `json:"extractProgress"`
|
||||
ResultURL map[string]string `json:"resultUrl"`
|
||||
ErrorMsg string `json:"errorMsg"`
|
||||
}
|
||||
|
||||
type extractProgress struct {
|
||||
TotalPages int `json:"totalPages"`
|
||||
ExtractedPages int `json:"extractedPages"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
}
|
||||
|
||||
func (c *Client) submitURL(ctx context.Context, model, fileURL string, payload interface{}, pageRanges, batchID string) (string, error) {
|
||||
body := map[string]interface{}{
|
||||
"fileUrl": fileURL,
|
||||
"model": model,
|
||||
"optionalPayload": payload,
|
||||
}
|
||||
if pageRanges != "" {
|
||||
body["pageRanges"] = pageRanges
|
||||
}
|
||||
if batchID != "" {
|
||||
body["batchId"] = batchID
|
||||
}
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "failed to encode submit request", Cause: err}}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.jobsURL, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return "", &NetworkError{PaddleOCRAPIError{Message: err.Error()}}
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.setClientPlatformHeader(req)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", classifyHTTPError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := raiseForResponse(resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
apiResp, err := decodeAPIResponse(resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var sr submitResponse
|
||||
if err := json.Unmarshal(apiResp.Data, &sr); err != nil {
|
||||
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "submit response is malformed", Cause: err}}
|
||||
}
|
||||
if sr.JobID == "" {
|
||||
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "submit response is missing jobId"}}
|
||||
}
|
||||
return sr.JobID, nil
|
||||
}
|
||||
|
||||
func (c *Client) submitFile(ctx context.Context, model, filePath string, payload interface{}, pageRanges, batchID string) (string, error) {
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
return "", &FileNotFoundError{Path: filePath, PaddleOCRAPIError: PaddleOCRAPIError{Message: "File not found: " + filePath}}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
|
||||
_ = w.WriteField("model", model)
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "failed to encode optionalPayload", Cause: err}}
|
||||
}
|
||||
if err := w.WriteField("optionalPayload", string(payloadJSON)); err != nil {
|
||||
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "failed to prepare multipart body", Cause: err}}
|
||||
}
|
||||
if pageRanges != "" {
|
||||
_ = w.WriteField("pageRanges", pageRanges)
|
||||
}
|
||||
if batchID != "" {
|
||||
_ = w.WriteField("batchId", batchID)
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", &FileNotFoundError{Path: filePath, PaddleOCRAPIError: PaddleOCRAPIError{Message: "Failed to open file: " + filePath, Cause: err}}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fw, err := w.CreateFormFile("file", filepath.Base(filePath))
|
||||
if err != nil {
|
||||
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "failed to prepare multipart body", Cause: err}}
|
||||
}
|
||||
if _, err := io.Copy(fw, file); err != nil {
|
||||
return "", &NetworkError{PaddleOCRAPIError{Message: "failed to read file content", Cause: err}}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "failed to finalize multipart body", Cause: err}}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.jobsURL, &buf)
|
||||
if err != nil {
|
||||
return "", &NetworkError{PaddleOCRAPIError{Message: err.Error()}}
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
c.setClientPlatformHeader(req)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", classifyHTTPError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := raiseForResponse(resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
apiResp, err := decodeAPIResponse(resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var sr submitResponse
|
||||
if err := json.Unmarshal(apiResp.Data, &sr); err != nil {
|
||||
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "submit response is malformed", Cause: err}}
|
||||
}
|
||||
if sr.JobID == "" {
|
||||
return "", &ResponseFormatError{PaddleOCRAPIError{Message: "submit response is missing jobId"}}
|
||||
}
|
||||
return sr.JobID, nil
|
||||
}
|
||||
|
||||
func (c *Client) getJobStatus(ctx context.Context, jobID string) (*jobStatusResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.jobsURL+"/"+jobID, nil)
|
||||
if err != nil {
|
||||
return nil, &NetworkError{PaddleOCRAPIError{Message: err.Error()}}
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
c.setClientPlatformHeader(req)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, classifyHTTPError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := raiseForResponse(resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiResp, err := decodeAPIResponse(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var status jobStatusResponse
|
||||
if err := json.Unmarshal(apiResp.Data, &status); err != nil {
|
||||
return nil, &ResponseFormatError{PaddleOCRAPIError{Message: "status response is malformed", Cause: err}}
|
||||
}
|
||||
if status.State == "" {
|
||||
return nil, &ResponseFormatError{PaddleOCRAPIError{Message: "status response is missing state"}}
|
||||
}
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
func (c *Client) getBatchStatus(ctx context.Context, batchID string) (*BatchStatus, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.jobsURL+"/batch/"+batchID, nil)
|
||||
if err != nil {
|
||||
return nil, &NetworkError{PaddleOCRAPIError{Message: err.Error(), Cause: err}}
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
c.setClientPlatformHeader(req)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, classifyHTTPError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := raiseForResponse(resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiResp, err := decodeAPIResponse(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload struct {
|
||||
ExtractResult []jobStatusResponse `json:"extractResult"`
|
||||
}
|
||||
if err := json.Unmarshal(apiResp.Data, &payload); err != nil {
|
||||
return nil, &ResponseFormatError{PaddleOCRAPIError{Message: "batch response is malformed", Cause: err}}
|
||||
}
|
||||
result := &BatchStatus{BatchID: batchID}
|
||||
for _, item := range payload.ExtractResult {
|
||||
jobID := item.JobID
|
||||
if jobID == "" {
|
||||
return nil, &ResponseFormatError{PaddleOCRAPIError{Message: "batch response item is missing jobId"}}
|
||||
}
|
||||
status, err := normalizeStatus(jobID, &item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Jobs = append(result.Jobs, status)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchJSONL(ctx context.Context, url string) ([]map[string]interface{}, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, &NetworkError{PaddleOCRAPIError{Message: err.Error()}}
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, classifyHTTPError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if err := raiseForResponse(resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
|
||||
var results []map[string]interface{}
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(line), &obj); err != nil {
|
||||
return nil, &ResultParseError{PaddleOCRAPIError{Message: "failed to parse JSONL result payload", Cause: err}}
|
||||
}
|
||||
results = append(results, obj)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func raiseForResponse(resp *http.Response) error {
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
msg := string(body)
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||
return &AuthError{PaddleOCRAPIError{Message: "Authentication failed: " + msg}}
|
||||
case resp.StatusCode == 400:
|
||||
return &InvalidRequestError{PaddleOCRAPIError{Message: "Bad request: " + msg}}
|
||||
case resp.StatusCode == 429:
|
||||
return &RateLimitError{APIError{StatusCode: 429, PaddleOCRAPIError: PaddleOCRAPIError{Message: "Rate limit exceeded: " + msg}}}
|
||||
case resp.StatusCode == 503 || resp.StatusCode == 504:
|
||||
return &ServiceUnavailableError{APIError{StatusCode: resp.StatusCode, PaddleOCRAPIError: PaddleOCRAPIError{Message: "Service unavailable: " + msg}}}
|
||||
default:
|
||||
return &APIError{StatusCode: resp.StatusCode, PaddleOCRAPIError: PaddleOCRAPIError{Message: msg}}
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAPIResponse(resp *http.Response) (*apiResponse, error) {
|
||||
var apiResp apiResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return nil, &ResponseFormatError{PaddleOCRAPIError{Message: "expected a JSON response body", Cause: err}}
|
||||
}
|
||||
if apiResp.Code != 0 {
|
||||
msg := apiResp.Msg
|
||||
if msg == "" {
|
||||
msg = "PaddleOCR official API request failed"
|
||||
}
|
||||
return nil, &APIError{StatusCode: resp.StatusCode, PaddleOCRAPIError: PaddleOCRAPIError{Message: msg}}
|
||||
}
|
||||
if len(apiResp.Data) == 0 || string(apiResp.Data) == "null" {
|
||||
return nil, &ResponseFormatError{PaddleOCRAPIError{Message: "response body is missing data"}}
|
||||
}
|
||||
return &apiResp, nil
|
||||
}
|
||||
|
||||
func classifyHTTPError(err error) error {
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
return &RequestTimeoutError{PaddleOCRAPIError: PaddleOCRAPIError{Message: err.Error(), Cause: err}}
|
||||
}
|
||||
return &NetworkError{PaddleOCRAPIError{Message: err.Error(), Cause: err}}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
@@ -0,0 +1,72 @@
|
||||
# PaddleOCR TypeScript SDK
|
||||
|
||||
English | [简体中文](README_cn.md)
|
||||
|
||||
TypeScript client for the PaddleOCR official API. It submits OCR and document
|
||||
parsing jobs to hosted PaddleOCR services; it does not perform local OCR
|
||||
inference.
|
||||
|
||||
Official user docs:
|
||||
|
||||
- [TypeScript SDK](../../docs/version3.x/inference_deployment/serving/paddleocr_official_api/typescript.md)
|
||||
- [TypeScript SDK (English)](../../docs/version3.x/inference_deployment/serving/paddleocr_official_api/typescript.en.md)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @paddleocr/api-sdk
|
||||
```
|
||||
|
||||
This package follows SemVer and is published as a public scoped npm package.
|
||||
|
||||
For local development:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Minimal Usage
|
||||
|
||||
Set `PADDLEOCR_ACCESS_TOKEN` or pass `token` to the client:
|
||||
|
||||
```bash
|
||||
export PADDLEOCR_ACCESS_TOKEN="your-access-token"
|
||||
```
|
||||
|
||||
```ts
|
||||
import { Model, PaddleOCRClient } from "@paddleocr/api-sdk";
|
||||
|
||||
const client = new PaddleOCRClient();
|
||||
const result = await client.ocr({
|
||||
model: Model.PPOCRv5,
|
||||
fileUrl: "https://example.com/invoice.pdf",
|
||||
});
|
||||
|
||||
console.log(result.jobId, result.pages.length);
|
||||
```
|
||||
|
||||
Specify `model: Model.PPOCRv6` (or `"PP-OCRv6"`) to use the PP-OCRv6 hosted OCR model.
|
||||
Use `Model.PPOCRv5Latin` (or `"PP-OCRv5-latin"`) for the PP-OCRv5 Latin-script hosted OCR model.
|
||||
|
||||
Document parsing defaults to PaddleOCR-VL-1.6:
|
||||
|
||||
```ts
|
||||
const doc = await client.parseDocument({
|
||||
filePath: "./report.pdf",
|
||||
options: {
|
||||
useChartRecognition: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(doc.jobId, doc.pages.length);
|
||||
```
|
||||
|
||||
## Build And Test
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
npm test
|
||||
npm audit --audit-level=moderate
|
||||
```
|
||||
@@ -0,0 +1,70 @@
|
||||
# PaddleOCR TypeScript SDK
|
||||
|
||||
[English](README.md) | 简体中文
|
||||
|
||||
面向 PaddleOCR 官方 API 的 TypeScript 客户端。它会把 OCR 和文档解析任务提交到 PaddleOCR 官方托管服务;不会在本地执行 OCR 推理。
|
||||
|
||||
正式用户文档:
|
||||
|
||||
- [TypeScript SDK](../../docs/version3.x/inference_deployment/serving/paddleocr_official_api/typescript.md)
|
||||
- [TypeScript SDK(英文)](../../docs/version3.x/inference_deployment/serving/paddleocr_official_api/typescript.en.md)
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install @paddleocr/api-sdk
|
||||
```
|
||||
|
||||
该包遵循语义化版本,并作为公开 scoped npm 包发布。
|
||||
|
||||
本地开发:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 最小示例
|
||||
|
||||
设置 `PADDLEOCR_ACCESS_TOKEN`,或在构造客户端时传入 `token`:
|
||||
|
||||
```bash
|
||||
export PADDLEOCR_ACCESS_TOKEN="your-access-token"
|
||||
```
|
||||
|
||||
```ts
|
||||
import { Model, PaddleOCRClient } from "@paddleocr/api-sdk";
|
||||
|
||||
const client = new PaddleOCRClient();
|
||||
const result = await client.ocr({
|
||||
model: Model.PPOCRv5,
|
||||
fileUrl: "https://example.com/invoice.pdf",
|
||||
});
|
||||
|
||||
console.log(result.jobId, result.pages.length);
|
||||
```
|
||||
|
||||
通过 `model: Model.PPOCRv6`(或 `"PP-OCRv6"`)可指定 PP-OCRv6 云端 OCR 模型。
|
||||
使用 `Model.PPOCRv5Latin`(或 `"PP-OCRv5-latin"`)可指定 PP-OCRv5 拉丁语系云端 OCR 模型。
|
||||
|
||||
文档解析默认使用 PaddleOCR-VL-1.6:
|
||||
|
||||
```ts
|
||||
const doc = await client.parseDocument({
|
||||
filePath: "./report.pdf",
|
||||
options: {
|
||||
useChartRecognition: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(doc.jobId, doc.pages.length);
|
||||
```
|
||||
|
||||
## 构建与测试
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
npm test
|
||||
npm audit --audit-level=moderate
|
||||
```
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { PaddleOCRClient, Model } from "@paddleocr/api-sdk";
|
||||
|
||||
async function main() {
|
||||
const client = new PaddleOCRClient();
|
||||
|
||||
// Convenience: doc parsing with local file
|
||||
const result = await client.parseDocument({
|
||||
model: Model.PPStructureV3,
|
||||
filePath: "./sample.pdf",
|
||||
options: { useChartRecognition: true },
|
||||
});
|
||||
|
||||
for (const page of result.pages) {
|
||||
console.log(page.markdownText);
|
||||
}
|
||||
|
||||
// Manual control: submit + concurrent wait
|
||||
const job1 = await client.submitOcr({ fileUrl: "https://example.com/f1.pdf" });
|
||||
const job2 = await client.submitDocumentParsing({
|
||||
model: Model.PPStructureV3,
|
||||
filePath: "./sample.pdf",
|
||||
});
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
client.waitOcrResult(job1.jobId),
|
||||
client.waitDocumentParsingResult(job2.jobId),
|
||||
]);
|
||||
|
||||
console.log("Job1 done:", r1);
|
||||
console.log("Job2 done:", r2);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { PaddleOCRClient, Model } from "@paddleocr/api-sdk";
|
||||
|
||||
async function main() {
|
||||
const client = new PaddleOCRClient();
|
||||
|
||||
// Convenience: OCR with URL
|
||||
const ocrResult = await client.ocr({
|
||||
fileUrl: "https://example.com/invoice.pdf",
|
||||
});
|
||||
|
||||
for (const page of ocrResult.pages) {
|
||||
console.log("Result:", page.prunedResult);
|
||||
console.log("Image:", page.ocrImageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
Generated
+2640
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@paddleocr/api-sdk",
|
||||
"version": "0.2.3",
|
||||
"description": "TypeScript SDK for the PaddleOCR official API",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"README_cn.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PaddlePaddle/PaddleOCR.git",
|
||||
"directory": "api_sdk/typescript"
|
||||
},
|
||||
"homepage": "https://github.com/PaddlePaddle/PaddleOCR/tree/main/api_sdk/typescript",
|
||||
"bugs": {
|
||||
"url": "https://github.com/PaddlePaddle/PaddleOCR/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"paddleocr",
|
||||
"ocr",
|
||||
"document-parsing",
|
||||
"api-sdk",
|
||||
"typescript",
|
||||
"official-api"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"test": "vitest run",
|
||||
"lint": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run lint && npm run build && npm test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { stat, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import { AuthError, FileNotFoundError, InvalidRequestError, ResultParseError } from "./errors.js";
|
||||
import { HttpClient } from "./internal/http.js";
|
||||
import { Poller } from "./internal/poller.js";
|
||||
import type { ClientOptions, DocParsingRequest, OCRRequest, SaveResourceOptions } from "./models.js";
|
||||
import { isDocumentParsingModel, isOCRModel, Model } from "./models.js";
|
||||
import type { BatchStatus, DocParsingResult, Job, JobStatus, OCRResult } from "./results.js";
|
||||
|
||||
const DEFAULT_BASE_URL = "https://paddleocr.aistudio-app.com";
|
||||
|
||||
interface ResourceSavePlan {
|
||||
resourceUrl: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export class PaddleOCRClient {
|
||||
private http: HttpClient;
|
||||
private poller: Poller;
|
||||
|
||||
constructor(options: ClientOptions = {}) {
|
||||
const token = options.token || process.env.PADDLEOCR_ACCESS_TOKEN || "";
|
||||
if (!token) {
|
||||
throw new AuthError("Token is required. Set PADDLEOCR_ACCESS_TOKEN or pass token option.");
|
||||
}
|
||||
const baseUrl = options.baseUrl || process.env.PADDLEOCR_BASE_URL || DEFAULT_BASE_URL;
|
||||
const requestTimeout = options.requestTimeout || options.timeout || 300000;
|
||||
const pollTimeout = options.pollTimeout || options.timeout || 600000;
|
||||
|
||||
this.http = new HttpClient(
|
||||
token,
|
||||
baseUrl,
|
||||
requestTimeout,
|
||||
options.fetch,
|
||||
options.clientPlatform,
|
||||
);
|
||||
this.poller = new Poller(this.http, pollTimeout);
|
||||
}
|
||||
|
||||
async ocr(req: OCRRequest, options?: { signal?: AbortSignal }): Promise<OCRResult> {
|
||||
const job = await this.submitOcr(req, options);
|
||||
return this.waitOcrResult(job, options);
|
||||
}
|
||||
|
||||
async parseDocument(req: DocParsingRequest, options?: { signal?: AbortSignal }): Promise<DocParsingResult> {
|
||||
const job = await this.submitDocumentParsing(req, options);
|
||||
return this.waitDocumentParsingResult(job, options);
|
||||
}
|
||||
|
||||
async submitOcr(req: OCRRequest, options?: { signal?: AbortSignal }): Promise<Job> {
|
||||
const model = req.model ?? Model.PPOCRv6;
|
||||
const jobId = await this.submit(model, "ocr", req, options?.signal);
|
||||
return { jobId, model, task: "ocr", pageRanges: req.pageRanges, batchId: req.batchId };
|
||||
}
|
||||
|
||||
async submitDocumentParsing(req: DocParsingRequest, options?: { signal?: AbortSignal }): Promise<Job> {
|
||||
const model = req.model ?? Model.PaddleOCRVL16;
|
||||
if (!isDocumentParsingModel(model)) {
|
||||
throw new InvalidRequestError(`Model ${model} is not a document parsing model.`);
|
||||
}
|
||||
const jobId = await this.submit(model, "document_parsing", req, options?.signal);
|
||||
return { jobId, model, task: "document_parsing", pageRanges: req.pageRanges, batchId: req.batchId };
|
||||
}
|
||||
|
||||
async waitOcrResult(job: Job | string, options?: { signal?: AbortSignal }): Promise<OCRResult> {
|
||||
const resolved = this.resolveJob(job, "ocr");
|
||||
const jsonlData = await this.poller.pollUntilDone(resolved.jobId, options?.signal);
|
||||
return this.parseOCRResult(resolved.jobId, jsonlData);
|
||||
}
|
||||
|
||||
async waitDocumentParsingResult(job: Job | string, options?: { signal?: AbortSignal }): Promise<DocParsingResult> {
|
||||
const resolved = this.resolveJob(job, "document_parsing");
|
||||
const jsonlData = await this.poller.pollUntilDone(resolved.jobId, options?.signal);
|
||||
return this.parseDocParsingResult(resolved.jobId, jsonlData);
|
||||
}
|
||||
|
||||
async getStatus(jobId: string, options?: { signal?: AbortSignal }): Promise<JobStatus> {
|
||||
return this.poller.getStatus(jobId, options?.signal);
|
||||
}
|
||||
|
||||
async getBatchStatus(batchId: string, options?: { signal?: AbortSignal }): Promise<BatchStatus> {
|
||||
return this.poller.getBatchStatus(batchId, options?.signal);
|
||||
}
|
||||
|
||||
async saveResource(
|
||||
resourceUrl: string,
|
||||
destination: string,
|
||||
options: SaveResourceOptions = {},
|
||||
): Promise<string> {
|
||||
return this.saveResourceUrl(resourceUrl, destination, options);
|
||||
}
|
||||
|
||||
async saveOcrResultResources(
|
||||
result: OCRResult,
|
||||
destination: string,
|
||||
options: SaveResourceOptions = {},
|
||||
): Promise<string[]> {
|
||||
return this.saveResultResources(result, destination, options);
|
||||
}
|
||||
|
||||
async saveDocumentParsingResultResources(
|
||||
result: DocParsingResult,
|
||||
destination: string,
|
||||
options: SaveResourceOptions = {},
|
||||
): Promise<string[]> {
|
||||
return this.saveResultResources(result, destination, options);
|
||||
}
|
||||
|
||||
private async saveResourceUrl(resourceUrl: string, destination: string, options: SaveResourceOptions): Promise<string> {
|
||||
if (!resourceUrl) {
|
||||
throw new InvalidRequestError("resourceUrl is required.");
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(resourceUrl);
|
||||
} catch (error) {
|
||||
throw new InvalidRequestError(`Invalid resource URL: ${resourceUrl}`, { cause: error });
|
||||
}
|
||||
const target = await this.resolveDestination(url, destination, options);
|
||||
const content = await this.http.fetchResource(resourceUrl);
|
||||
await writeFile(target, Buffer.from(content), { flag: options.overwrite ? "w" : "wx" });
|
||||
return target;
|
||||
}
|
||||
|
||||
private async saveResultResources(
|
||||
result: OCRResult | DocParsingResult,
|
||||
destination: string,
|
||||
options: SaveResourceOptions,
|
||||
): Promise<string[]> {
|
||||
await this.requireExistingDirectory(destination);
|
||||
const plans = this.collectResultResourcePlans(result);
|
||||
const targets = plans.map((plan) => join(destination, plan.filename));
|
||||
for (const target of targets) {
|
||||
await this.requireWritableTarget(target, options);
|
||||
}
|
||||
this.requireUniqueTargets(targets, options);
|
||||
const savedPaths: string[] = [];
|
||||
for (const [index, plan] of plans.entries()) {
|
||||
await this.saveResourceUrl(plan.resourceUrl, targets[index], options);
|
||||
savedPaths.push(targets[index]);
|
||||
}
|
||||
return savedPaths;
|
||||
}
|
||||
|
||||
private collectResultResourcePlans(result: OCRResult | DocParsingResult): ResourceSavePlan[] {
|
||||
if (isDocParsingResult(result)) {
|
||||
return result.pages.flatMap((page) => [
|
||||
...this.collectMappedResourcePlans(page.markdownImages),
|
||||
...this.collectMappedResourcePlans(page.outputImages),
|
||||
]);
|
||||
}
|
||||
return result.pages.flatMap((page, index) => {
|
||||
if (!page.ocrImageUrl) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
resourceUrl: page.ocrImageUrl,
|
||||
filename: `ocr-page-${index + 1}${resourceExtension(page.ocrImageUrl)}`,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
private collectMappedResourcePlans(resources: Record<string, string>): ResourceSavePlan[] {
|
||||
return Object.keys(resources)
|
||||
.sort()
|
||||
.map((key) => ({
|
||||
resourceUrl: resources[key],
|
||||
filename: safeMapKeyFilename(key),
|
||||
}));
|
||||
}
|
||||
|
||||
private async requireExistingDirectory(destination: string): Promise<void> {
|
||||
let destinationStat;
|
||||
try {
|
||||
destinationStat = await stat(destination);
|
||||
} catch {
|
||||
throw new FileNotFoundError(destination);
|
||||
}
|
||||
if (!destinationStat.isDirectory()) {
|
||||
throw new InvalidRequestError(`Destination must be an existing directory: ${destination}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async requireWritableTarget(target: string, options: SaveResourceOptions): Promise<void> {
|
||||
try {
|
||||
await stat(target);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!options.overwrite) {
|
||||
throw new InvalidRequestError(`Destination already exists: ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
private requireUniqueTargets(targets: string[], options: SaveResourceOptions): void {
|
||||
if (options.overwrite) {
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const target of targets) {
|
||||
if (seen.has(target)) {
|
||||
throw new InvalidRequestError(`Destination already exists: ${target}`);
|
||||
}
|
||||
seen.add(target);
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveDestination(url: URL, destination: string, options: SaveResourceOptions): Promise<string> {
|
||||
let destinationStat;
|
||||
try {
|
||||
destinationStat = await stat(destination);
|
||||
} catch {
|
||||
destinationStat = undefined;
|
||||
}
|
||||
const target = destinationStat?.isDirectory() ? join(destination, safeUrlBasename(url)) : destination;
|
||||
await this.requireWritableTarget(target, options);
|
||||
const parent = dirname(target);
|
||||
try {
|
||||
const parentStat = await stat(parent);
|
||||
if (!parentStat.isDirectory()) {
|
||||
throw new InvalidRequestError(`Destination parent must be a directory: ${parent}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidRequestError) {
|
||||
throw error;
|
||||
}
|
||||
throw new FileNotFoundError(parent, { cause: error });
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private async submit(
|
||||
model: string,
|
||||
task: Job["task"],
|
||||
req: { fileUrl?: string; filePath?: string; pageRanges?: string; batchId?: string; options?: object },
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
if (!req.fileUrl && !req.filePath) {
|
||||
throw new InvalidRequestError("Either fileUrl or filePath is required.");
|
||||
}
|
||||
if (req.fileUrl && req.filePath) {
|
||||
throw new InvalidRequestError("fileUrl and filePath are mutually exclusive.");
|
||||
}
|
||||
|
||||
this.validateModelForTask(model, task);
|
||||
const payload = req.options || {};
|
||||
|
||||
if (req.fileUrl) {
|
||||
return this.http.submitUrl(model, req.fileUrl, payload, {
|
||||
pageRanges: req.pageRanges,
|
||||
batchId: req.batchId,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
return this.http.submitFile(model, req.filePath!, payload, {
|
||||
pageRanges: req.pageRanges,
|
||||
batchId: req.batchId,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
private parseOCRResult(jobId: string, jsonlData: unknown[]): OCRResult {
|
||||
const dataInfo: Record<string, unknown> = {};
|
||||
const pages = jsonlData.flatMap((lineObj) => {
|
||||
if (!isRecord(lineObj) || !isRecord(lineObj.result) || !Array.isArray(lineObj.result.ocrResults)) {
|
||||
throw new ResultParseError("OCR result item is missing result.ocrResults.");
|
||||
}
|
||||
if (isRecord(lineObj.result.dataInfo)) {
|
||||
Object.assign(dataInfo, lineObj.result.dataInfo);
|
||||
}
|
||||
return lineObj.result.ocrResults.map((item) => {
|
||||
if (!isRecord(item) || !("prunedResult" in item)) {
|
||||
throw new ResultParseError("OCR result page is missing prunedResult.");
|
||||
}
|
||||
return {
|
||||
prunedResult: item.prunedResult,
|
||||
ocrImageUrl: typeof item.ocrImage === "string" ? item.ocrImage : undefined,
|
||||
docPreprocessingImageUrl: typeof item.docPreprocessingImage === "string" ? item.docPreprocessingImage : undefined,
|
||||
inputImageUrl: typeof item.inputImage === "string" ? item.inputImage : undefined,
|
||||
raw: item,
|
||||
};
|
||||
});
|
||||
});
|
||||
return { jobId, pages, dataInfo };
|
||||
}
|
||||
|
||||
private parseDocParsingResult(jobId: string, jsonlData: unknown[]): DocParsingResult {
|
||||
const dataInfo: Record<string, unknown> = {};
|
||||
const pages = jsonlData.flatMap((lineObj) => {
|
||||
if (!isRecord(lineObj) || !isRecord(lineObj.result) || !Array.isArray(lineObj.result.layoutParsingResults)) {
|
||||
throw new ResultParseError("Document parsing result item is missing result.layoutParsingResults.");
|
||||
}
|
||||
if (isRecord(lineObj.result.dataInfo)) {
|
||||
Object.assign(dataInfo, lineObj.result.dataInfo);
|
||||
}
|
||||
return lineObj.result.layoutParsingResults.map((item) => {
|
||||
if (!isRecord(item) || !isRecord(item.markdown) || typeof item.markdown.text !== "string") {
|
||||
throw new ResultParseError("Document parsing result page is missing markdown.text.");
|
||||
}
|
||||
return {
|
||||
markdownText: item.markdown.text,
|
||||
markdownImages: isRecord(item.markdown.images) ? stringMap(item.markdown.images) : {},
|
||||
outputImages: isRecord(item.outputImages) ? stringMap(item.outputImages) : {},
|
||||
prunedResult: item.prunedResult,
|
||||
inputImageUrl: typeof item.inputImage === "string" ? item.inputImage : undefined,
|
||||
exports: isRecord(item.exports) ? item.exports : {},
|
||||
markdown: item.markdown,
|
||||
raw: item,
|
||||
};
|
||||
});
|
||||
});
|
||||
return { jobId, pages, dataInfo };
|
||||
}
|
||||
|
||||
private resolveJob(job: Job | string, expectedTask: Job["task"]): Job {
|
||||
if (typeof job === "string") {
|
||||
return {
|
||||
jobId: job,
|
||||
model: expectedTask === "ocr" ? Model.PPOCRv6 : Model.PaddleOCRVL16,
|
||||
task: expectedTask,
|
||||
};
|
||||
}
|
||||
if (job.task !== expectedTask) {
|
||||
throw new InvalidRequestError(`Job ${job.jobId} is a ${job.task} job, not a ${expectedTask} job.`);
|
||||
}
|
||||
this.validateModelForTask(job.model, expectedTask);
|
||||
return job;
|
||||
}
|
||||
|
||||
private validateModelForTask(model: string, task: Job["task"]): void {
|
||||
if (task === "ocr" && !isOCRModel(model)) {
|
||||
throw new InvalidRequestError(`Model ${model} is not an OCR model.`);
|
||||
}
|
||||
if (task === "document_parsing" && !isDocumentParsingModel(model)) {
|
||||
throw new InvalidRequestError(`Model ${model} is not a document parsing model.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stringMap(value: Record<string, unknown>): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
if (typeof val === "string") {
|
||||
result[key] = val;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isDocParsingResult(result: OCRResult | DocParsingResult): result is DocParsingResult {
|
||||
return result.pages.some((page) => "markdownText" in page);
|
||||
}
|
||||
|
||||
function safeMapKeyFilename(key: string): string {
|
||||
if (!key || key === "." || key === ".." || key.includes("/") || key.includes("\\") || key.startsWith(".")) {
|
||||
throw new InvalidRequestError(`Unsafe resource filename: ${key}`);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function safeUrlBasename(url: URL): string {
|
||||
const name = basename(url.pathname) || "resource";
|
||||
if (name === "." || name === "..") {
|
||||
return "resource";
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function resourceExtension(resourceUrl: string): string {
|
||||
try {
|
||||
const url = new URL(resourceUrl);
|
||||
return extname(url.pathname);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export class PaddleOCRAPIError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message);
|
||||
this.name = "PaddleOCRAPIError";
|
||||
this.cause = options?.cause;
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthError extends PaddleOCRAPIError {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "AuthError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidRequestError extends PaddleOCRAPIError {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "InvalidRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
export class APIError extends PaddleOCRAPIError {
|
||||
statusCode: number;
|
||||
constructor(statusCode: number, message: string, options?: ErrorOptions) {
|
||||
super(`HTTP ${statusCode}: ${message}`, options);
|
||||
this.name = "APIError";
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends APIError {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(429, message, options);
|
||||
this.name = "RateLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceUnavailableError extends APIError {
|
||||
constructor(statusCode: number, message: string, options?: ErrorOptions) {
|
||||
super(statusCode, message, options);
|
||||
this.name = "ServiceUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export class JobFailedError extends PaddleOCRAPIError {
|
||||
jobId: string;
|
||||
errorMsg: string;
|
||||
constructor(jobId: string, errorMsg: string, options?: ErrorOptions) {
|
||||
super(`Job ${jobId} failed: ${errorMsg}`, options);
|
||||
this.name = "JobFailedError";
|
||||
this.jobId = jobId;
|
||||
this.errorMsg = errorMsg;
|
||||
}
|
||||
}
|
||||
|
||||
export class RequestTimeoutError extends PaddleOCRAPIError {
|
||||
timeoutMs: number;
|
||||
constructor(timeoutMs: number, options?: ErrorOptions) {
|
||||
super(`Request timed out after ${timeoutMs}ms`, options);
|
||||
this.name = "RequestTimeoutError";
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
export class PollTimeoutError extends PaddleOCRAPIError {
|
||||
jobId: string;
|
||||
timeoutMs: number;
|
||||
constructor(jobId: string, timeoutMs: number, options?: ErrorOptions) {
|
||||
super(`Timed out after ${timeoutMs}ms waiting for job ${jobId}`, options);
|
||||
this.name = "PollTimeoutError";
|
||||
this.jobId = jobId;
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends PaddleOCRAPIError {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "NetworkError";
|
||||
}
|
||||
}
|
||||
|
||||
export class FileNotFoundError extends PaddleOCRAPIError {
|
||||
path: string;
|
||||
constructor(path: string, options?: ErrorOptions) {
|
||||
super(`File not found: ${path}`, options);
|
||||
this.name = "FileNotFoundError";
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
export class ResponseFormatError extends PaddleOCRAPIError {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "ResponseFormatError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ResultParseError extends PaddleOCRAPIError {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "ResultParseError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export { PaddleOCRClient } from "./client.js";
|
||||
export { Model, isDocumentParsingModel, isOCRModel, isVLModel } from "./models.js";
|
||||
export type {
|
||||
ClientOptions,
|
||||
DocParsingOptions,
|
||||
DocParsingRequest,
|
||||
OCROptions,
|
||||
OCRRequest,
|
||||
PaddleOCRVLOptions,
|
||||
PPStructureV3Options,
|
||||
SaveResourceOptions,
|
||||
} from "./models.js";
|
||||
export type {
|
||||
BatchStatus,
|
||||
DocParsingPage,
|
||||
DocParsingResult,
|
||||
Job,
|
||||
JobStatus,
|
||||
OCRPage,
|
||||
OCRResult,
|
||||
Progress,
|
||||
} from "./results.js";
|
||||
export {
|
||||
APIError,
|
||||
AuthError,
|
||||
FileNotFoundError,
|
||||
InvalidRequestError,
|
||||
JobFailedError,
|
||||
NetworkError,
|
||||
PaddleOCRAPIError,
|
||||
PollTimeoutError,
|
||||
RateLimitError,
|
||||
RequestTimeoutError,
|
||||
ResponseFormatError,
|
||||
ResultParseError,
|
||||
ServiceUnavailableError,
|
||||
} from "./errors.js";
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
/** Value to reject/throw when an operation was aborted via `AbortSignal` (aligns with `fetch` + `DOMException`). */
|
||||
export function userAbortReason(signal: AbortSignal): unknown {
|
||||
return signal.reason !== undefined
|
||||
? signal.reason
|
||||
: new DOMException("The user aborted a request.", "AbortError");
|
||||
}
|
||||
|
||||
export function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (!signal?.aborted) return;
|
||||
throw userAbortReason(signal);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import {
|
||||
APIError,
|
||||
AuthError,
|
||||
InvalidRequestError,
|
||||
NetworkError,
|
||||
RateLimitError,
|
||||
RequestTimeoutError,
|
||||
ResponseFormatError,
|
||||
ResultParseError,
|
||||
ServiceUnavailableError,
|
||||
} from "../errors.js";
|
||||
import { userAbortReason } from "./abort.js";
|
||||
|
||||
const DEFAULT_BASE_URL = "https://paddleocr.aistudio-app.com";
|
||||
const API_PATH = "/api/v2/ocr/jobs";
|
||||
|
||||
interface SubmitOptions {
|
||||
pageRanges?: string;
|
||||
batchId?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface APIResponse<T> {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface SubmitResponse {
|
||||
jobId: string;
|
||||
}
|
||||
|
||||
export class HttpClient {
|
||||
private baseUrl: string;
|
||||
private jobsUrl: string;
|
||||
private token: string;
|
||||
private requestTimeout: number;
|
||||
private fetchImpl: typeof fetch;
|
||||
private clientPlatform?: string;
|
||||
|
||||
constructor(
|
||||
token: string,
|
||||
baseUrl: string = DEFAULT_BASE_URL,
|
||||
requestTimeout: number = 300000,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
clientPlatform?: string,
|
||||
) {
|
||||
this.token = token;
|
||||
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
||||
this.jobsUrl = `${this.baseUrl}${API_PATH}`;
|
||||
this.requestTimeout = requestTimeout;
|
||||
this.fetchImpl = fetchImpl;
|
||||
this.clientPlatform = clientPlatform;
|
||||
}
|
||||
|
||||
async submitUrl(model: string, fileUrl: string, optionalPayload: object, options: SubmitOptions = {}): Promise<string> {
|
||||
const body: Record<string, unknown> = { fileUrl, model, optionalPayload };
|
||||
if (options.pageRanges !== undefined) {
|
||||
body.pageRanges = options.pageRanges;
|
||||
}
|
||||
if (options.batchId !== undefined) {
|
||||
body.batchId = options.batchId;
|
||||
}
|
||||
const data = await this.fetchJson<SubmitResponse>(this.jobsUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}, options.signal);
|
||||
return requireJobId(data);
|
||||
}
|
||||
|
||||
async submitFile(model: string, filePath: string, optionalPayload: object, options: SubmitOptions = {}): Promise<string> {
|
||||
const fs = await import("fs");
|
||||
const path = await import("path");
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const { FileNotFoundError } = await import("../errors.js");
|
||||
throw new FileNotFoundError(filePath);
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append("model", model);
|
||||
form.append("optionalPayload", JSON.stringify(optionalPayload));
|
||||
if (options.pageRanges !== undefined) {
|
||||
form.append("pageRanges", options.pageRanges);
|
||||
}
|
||||
if (options.batchId !== undefined) {
|
||||
form.append("batchId", options.batchId);
|
||||
}
|
||||
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
const blob = new Blob([fileBuffer]);
|
||||
form.append("file", blob, path.basename(filePath));
|
||||
|
||||
const data = await this.fetchJson<SubmitResponse>(this.jobsUrl, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
}, options.signal);
|
||||
return requireJobId(data);
|
||||
}
|
||||
|
||||
async getJobStatus(jobId: string, signal?: AbortSignal, timeoutMs?: number): Promise<unknown> {
|
||||
return this.fetchJson<unknown>(
|
||||
`${this.jobsUrl}/${encodeURIComponent(jobId)}`,
|
||||
{ method: "GET" },
|
||||
signal,
|
||||
true,
|
||||
timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
async getBatchStatus(batchId: string, signal?: AbortSignal, timeoutMs?: number): Promise<unknown> {
|
||||
return this.fetchJson<unknown>(
|
||||
`${this.jobsUrl}/batch/${encodeURIComponent(batchId)}`,
|
||||
{ method: "GET" },
|
||||
signal,
|
||||
true,
|
||||
timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
async fetchJsonl(url: string, signal?: AbortSignal, timeoutMs?: number): Promise<unknown[]> {
|
||||
const resp = await this.fetch(url, { method: "GET" }, signal, false, timeoutMs);
|
||||
const text = await resp.text();
|
||||
try {
|
||||
return text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => JSON.parse(line) as unknown);
|
||||
} catch (error) {
|
||||
throw new ResultParseError("Failed to parse JSONL result payload.", { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async fetchResource(url: string, signal?: AbortSignal, timeoutMs?: number): Promise<ArrayBuffer> {
|
||||
const resp = await this.fetch(url, { method: "GET" }, signal, false, timeoutMs);
|
||||
return resp.arrayBuffer();
|
||||
}
|
||||
|
||||
private async fetchJson<T>(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
withAuth: boolean = true,
|
||||
timeoutMs?: number,
|
||||
): Promise<T> {
|
||||
const resp = await this.fetch(url, init, signal, withAuth, timeoutMs);
|
||||
let payload: APIResponse<T>;
|
||||
try {
|
||||
payload = await resp.json() as APIResponse<T>;
|
||||
} catch (error) {
|
||||
throw new ResponseFormatError("Expected a JSON response body.", { cause: error });
|
||||
}
|
||||
if (payload.code !== undefined && payload.code !== 0) {
|
||||
throw new APIError(resp.status, payload.msg || "PaddleOCR official API request failed.");
|
||||
}
|
||||
if (!payload || typeof payload !== "object" || !("data" in payload)) {
|
||||
throw new ResponseFormatError("Response body is missing data.");
|
||||
}
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
private async fetch(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
withAuth: boolean = true,
|
||||
timeoutMs?: number,
|
||||
): Promise<Response> {
|
||||
const headers: Record<string, string> = {
|
||||
...(init.headers as Record<string, string> || {}),
|
||||
};
|
||||
if (withAuth) {
|
||||
headers.Authorization = `Bearer ${this.token}`;
|
||||
if (this.clientPlatform) {
|
||||
headers["Client-Platform"] = this.clientPlatform;
|
||||
}
|
||||
}
|
||||
|
||||
let resp: Response;
|
||||
const timeoutController = new AbortController();
|
||||
const effectiveTimeout = Math.max(0, Math.min(this.requestTimeout, timeoutMs ?? this.requestTimeout));
|
||||
const timeoutID = setTimeout(() => timeoutController.abort(), effectiveTimeout);
|
||||
const abortController = new AbortController();
|
||||
const abort = () => abortController.abort();
|
||||
timeoutController.signal.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
} else {
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
}
|
||||
try {
|
||||
resp = await this.fetchImpl(url, {
|
||||
...init,
|
||||
headers,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
if (signal?.aborted) {
|
||||
throw userAbortReason(signal);
|
||||
}
|
||||
if (timeoutController.signal.aborted) {
|
||||
throw new RequestTimeoutError(effectiveTimeout, { cause: e });
|
||||
}
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
throw new NetworkError(`Connection failed: ${message}`);
|
||||
} finally {
|
||||
clearTimeout(timeoutID);
|
||||
signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
|
||||
if (resp.ok) return resp;
|
||||
|
||||
let text = await resp.text();
|
||||
try {
|
||||
const payload = JSON.parse(text) as { msg?: string; message?: string; errorMsg?: string };
|
||||
text = payload.msg || payload.message || payload.errorMsg || text;
|
||||
} catch {
|
||||
// Keep raw response text.
|
||||
}
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
throw new AuthError(`Authentication failed: ${text}`);
|
||||
} else if (resp.status === 400) {
|
||||
throw new InvalidRequestError(`Bad request: ${text}`);
|
||||
} else if (resp.status === 429) {
|
||||
throw new RateLimitError(`Rate limit exceeded: ${text}`);
|
||||
} else if (resp.status === 503 || resp.status === 504) {
|
||||
throw new ServiceUnavailableError(resp.status, `Service unavailable: ${text}`);
|
||||
} else {
|
||||
throw new APIError(resp.status, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requireJobId(data: SubmitResponse): string {
|
||||
if (!data || typeof data.jobId !== "string" || data.jobId.length === 0) {
|
||||
throw new ResponseFormatError("Submit response is missing jobId.");
|
||||
}
|
||||
return data.jobId;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { JobFailedError, PollTimeoutError, RequestTimeoutError, ResponseFormatError } from "../errors.js";
|
||||
import type { BatchStatus, JobStatus, Progress } from "../results.js";
|
||||
import { throwIfAborted, userAbortReason } from "./abort.js";
|
||||
import { HttpClient } from "./http.js";
|
||||
|
||||
const INITIAL_INTERVAL = 3000;
|
||||
const MULTIPLIER = 1.5;
|
||||
const MAX_INTERVAL = 15000;
|
||||
const MAX_WAIT_TIME = 600000;
|
||||
|
||||
export class Poller {
|
||||
private http: HttpClient;
|
||||
private maxWaitTime: number;
|
||||
|
||||
constructor(http: HttpClient, maxWaitTime: number = MAX_WAIT_TIME) {
|
||||
this.http = http;
|
||||
this.maxWaitTime = maxWaitTime;
|
||||
}
|
||||
|
||||
async pollUntilDone(jobId: string, signal?: AbortSignal): Promise<unknown[]> {
|
||||
let interval = INITIAL_INTERVAL;
|
||||
const deadline = Date.now() + this.maxWaitTime;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
throwIfAborted(signal);
|
||||
|
||||
const remaining = deadline - Date.now();
|
||||
const data = await this.withPollTimeout(jobId, remaining, () => this.http.getJobStatus(jobId, signal, remaining));
|
||||
const status = normalizeStatus(jobId, data);
|
||||
|
||||
if (status.state === "done") {
|
||||
const jsonUrl = resultJsonUrl(data);
|
||||
const resultRemaining = deadline - Date.now();
|
||||
return await this.withPollTimeout(jobId, resultRemaining, () => this.http.fetchJsonl(jsonUrl, signal, resultRemaining));
|
||||
}
|
||||
|
||||
if (status.state === "failed") {
|
||||
throw new JobFailedError(jobId, status.errorMsg || "Unknown error");
|
||||
}
|
||||
|
||||
await this.sleep(Math.min(interval, Math.max(0, deadline - Date.now())), signal);
|
||||
interval = Math.min(interval * MULTIPLIER, MAX_INTERVAL);
|
||||
}
|
||||
|
||||
throw new PollTimeoutError(jobId, this.maxWaitTime);
|
||||
}
|
||||
|
||||
async getStatus(jobId: string, signal?: AbortSignal): Promise<JobStatus> {
|
||||
return normalizeStatus(jobId, await this.http.getJobStatus(jobId, signal));
|
||||
}
|
||||
|
||||
async getBatchStatus(batchId: string, signal?: AbortSignal): Promise<BatchStatus> {
|
||||
const data = await this.http.getBatchStatus(batchId, signal);
|
||||
if (!isRecord(data) || !Array.isArray(data.extractResult)) {
|
||||
throw new ResponseFormatError("Batch response is missing extractResult.");
|
||||
}
|
||||
return {
|
||||
batchId,
|
||||
jobs: data.extractResult.map((item) => {
|
||||
if (!isRecord(item) || typeof item.jobId !== "string") {
|
||||
throw new ResponseFormatError("Batch extractResult item is missing jobId.");
|
||||
}
|
||||
return normalizeStatus(item.jobId, item);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
if (!signal) return;
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(userAbortReason(signal));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async withPollTimeout<T>(jobId: string, remainingMs: number, operation: () => Promise<T>): Promise<T> {
|
||||
if (remainingMs <= 0) {
|
||||
throw new PollTimeoutError(jobId, this.maxWaitTime);
|
||||
}
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof RequestTimeoutError && error.timeoutMs === remainingMs) {
|
||||
throw new PollTimeoutError(jobId, this.maxWaitTime, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStatus(jobId: string, data: unknown): JobStatus {
|
||||
if (!isRecord(data) || typeof data.state !== "string") {
|
||||
throw new ResponseFormatError("Status response is missing state.");
|
||||
}
|
||||
if (!["pending", "running", "done", "failed"].includes(data.state)) {
|
||||
throw new ResponseFormatError(`Unknown job state: ${data.state}`);
|
||||
}
|
||||
return {
|
||||
jobId,
|
||||
state: data.state as JobStatus["state"],
|
||||
progress: normalizeProgress(data.extractProgress),
|
||||
resultUrl: isRecord(data.resultUrl) ? stringMap(data.resultUrl) : undefined,
|
||||
errorMsg: typeof data.errorMsg === "string" ? data.errorMsg : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProgress(progress: unknown): Progress | undefined {
|
||||
if (progress === undefined || progress === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(progress)) {
|
||||
throw new ResponseFormatError("Status progress must be an object.");
|
||||
}
|
||||
return {
|
||||
totalPages: typeof progress.totalPages === "number" ? progress.totalPages : 0,
|
||||
extractedPages: typeof progress.extractedPages === "number" ? progress.extractedPages : 0,
|
||||
startTime: typeof progress.startTime === "string" ? progress.startTime : undefined,
|
||||
endTime: typeof progress.endTime === "string" ? progress.endTime : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resultJsonUrl(data: unknown): string {
|
||||
if (!isRecord(data) || !isRecord(data.resultUrl) || typeof data.resultUrl.jsonUrl !== "string") {
|
||||
throw new ResponseFormatError("Done job response is missing resultUrl.jsonUrl.");
|
||||
}
|
||||
return data.resultUrl.jsonUrl;
|
||||
}
|
||||
|
||||
function stringMap(value: Record<string, unknown>): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
if (typeof val === "string") {
|
||||
result[key] = val;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export enum Model {
|
||||
PPOCRv5 = "PP-OCRv5",
|
||||
PPOCRv5Latin = "PP-OCRv5-latin",
|
||||
PPOCRv6 = "PP-OCRv6",
|
||||
PPStructureV3 = "PP-StructureV3",
|
||||
PaddleOCRVL = "PaddleOCR-VL",
|
||||
PaddleOCRVL15 = "PaddleOCR-VL-1.5",
|
||||
PaddleOCRVL16 = "PaddleOCR-VL-1.6",
|
||||
}
|
||||
|
||||
const OCR_MODELS = new Set<string>([Model.PPOCRv5, Model.PPOCRv5Latin, Model.PPOCRv6]);
|
||||
const DOCUMENT_PARSING_MODELS = new Set<string>([
|
||||
Model.PPStructureV3,
|
||||
Model.PaddleOCRVL,
|
||||
Model.PaddleOCRVL15,
|
||||
Model.PaddleOCRVL16,
|
||||
]);
|
||||
const VL_MODELS = new Set<string>([
|
||||
Model.PaddleOCRVL,
|
||||
Model.PaddleOCRVL15,
|
||||
Model.PaddleOCRVL16,
|
||||
]);
|
||||
|
||||
export function isOCRModel(
|
||||
model: string
|
||||
): model is Model.PPOCRv5 | Model.PPOCRv5Latin | Model.PPOCRv6 {
|
||||
return OCR_MODELS.has(model);
|
||||
}
|
||||
|
||||
export function isDocumentParsingModel(model: string): boolean {
|
||||
return DOCUMENT_PARSING_MODELS.has(model);
|
||||
}
|
||||
|
||||
export function isVLModel(model: string): boolean {
|
||||
return VL_MODELS.has(model);
|
||||
}
|
||||
|
||||
export interface OCROptions {
|
||||
useDocOrientationClassify?: boolean;
|
||||
useDocUnwarping?: boolean;
|
||||
useTextlineOrientation?: boolean;
|
||||
textDetLimitSideLen?: number;
|
||||
textDetLimitType?: string;
|
||||
textDetThresh?: number;
|
||||
textDetBoxThresh?: number;
|
||||
textDetUnclipRatio?: number;
|
||||
textRecScoreThresh?: number;
|
||||
visualize?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PPStructureV3Options {
|
||||
useDocOrientationClassify?: boolean;
|
||||
useDocUnwarping?: boolean;
|
||||
useTextlineOrientation?: boolean;
|
||||
useSealRecognition?: boolean;
|
||||
useTableRecognition?: boolean;
|
||||
useFormulaRecognition?: boolean;
|
||||
useChartRecognition?: boolean;
|
||||
useRegionDetection?: boolean;
|
||||
layoutThreshold?: number | Record<string, number>;
|
||||
layoutNms?: boolean;
|
||||
layoutUnclipRatio?: number | number[] | Record<string, number>;
|
||||
layoutMergeBboxesMode?: string | Record<string, string>;
|
||||
formatBlockContent?: boolean;
|
||||
textDetLimitSideLen?: number;
|
||||
textDetLimitType?: string;
|
||||
textDetThresh?: number;
|
||||
textDetBoxThresh?: number;
|
||||
textDetUnclipRatio?: number;
|
||||
textRecScoreThresh?: number;
|
||||
useWiredTableCellsTransToHtml?: boolean;
|
||||
useWirelessTableCellsTransToHtml?: boolean;
|
||||
useTableOrientationClassify?: boolean;
|
||||
useOcrResultsWithTableCells?: boolean;
|
||||
useE2eWiredTableRecModel?: boolean;
|
||||
useE2eWirelessTableRecModel?: boolean;
|
||||
markdownIgnoreLabels?: string[];
|
||||
prettifyMarkdown?: boolean;
|
||||
showFormulaNumber?: boolean;
|
||||
returnMarkdownImages?: boolean;
|
||||
outputFormats?: string[];
|
||||
visualize?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PaddleOCRVLOptions {
|
||||
useDocOrientationClassify?: boolean;
|
||||
useDocUnwarping?: boolean;
|
||||
useLayoutDetection?: boolean;
|
||||
useChartRecognition?: boolean;
|
||||
useSealRecognition?: boolean;
|
||||
useOcrForImageBlock?: boolean;
|
||||
layoutThreshold?: number | Record<string, number>;
|
||||
layoutNms?: boolean;
|
||||
layoutUnclipRatio?: number | number[] | Record<string, number>;
|
||||
layoutMergeBboxesMode?: string | Record<string, string>;
|
||||
layoutShapeMode?: "rect" | "quad" | "poly" | "auto";
|
||||
promptLabel?: "ocr" | "formula" | "table" | "chart" | "seal" | "spotting";
|
||||
formatBlockContent?: boolean;
|
||||
repetitionPenalty?: number;
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
minPixels?: number;
|
||||
maxPixels?: number;
|
||||
maxNewTokens?: number;
|
||||
vlmExtraArgs?: Record<string, unknown>;
|
||||
mergeLayoutBlocks?: boolean;
|
||||
markdownIgnoreLabels?: string[];
|
||||
prettifyMarkdown?: boolean;
|
||||
showFormulaNumber?: boolean;
|
||||
restructurePages?: boolean;
|
||||
mergeTables?: boolean;
|
||||
relevelTitles?: boolean;
|
||||
returnMarkdownImages?: boolean;
|
||||
outputFormats?: string[];
|
||||
visualize?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type DocParsingOptions = PPStructureV3Options | PaddleOCRVLOptions;
|
||||
|
||||
export interface OCRRequest {
|
||||
model?: Model | string;
|
||||
fileUrl?: string;
|
||||
filePath?: string;
|
||||
pageRanges?: string;
|
||||
batchId?: string;
|
||||
options?: OCROptions;
|
||||
}
|
||||
|
||||
export interface DocParsingRequest {
|
||||
model?: Model | string;
|
||||
fileUrl?: string;
|
||||
filePath?: string;
|
||||
pageRanges?: string;
|
||||
batchId?: string;
|
||||
options?: DocParsingOptions;
|
||||
}
|
||||
|
||||
export interface ClientOptions {
|
||||
token?: string;
|
||||
baseUrl?: string;
|
||||
timeout?: number;
|
||||
requestTimeout?: number;
|
||||
pollTimeout?: number;
|
||||
clientPlatform?: string;
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface SaveResourceOptions {
|
||||
overwrite?: boolean;
|
||||
filename?: string;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export interface OCRPage {
|
||||
prunedResult: unknown;
|
||||
ocrImageUrl?: string;
|
||||
docPreprocessingImageUrl?: string;
|
||||
inputImageUrl?: string;
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
export interface DocParsingPage {
|
||||
markdownText: string;
|
||||
markdownImages: Record<string, string>;
|
||||
outputImages: Record<string, string>;
|
||||
prunedResult?: unknown;
|
||||
inputImageUrl?: string;
|
||||
exports?: Record<string, unknown>;
|
||||
markdown?: Record<string, unknown>;
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
export interface OCRResult {
|
||||
jobId: string;
|
||||
pages: OCRPage[];
|
||||
dataInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DocParsingResult {
|
||||
jobId: string;
|
||||
pages: DocParsingPage[];
|
||||
dataInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Progress {
|
||||
totalPages: number;
|
||||
extractedPages: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
jobId: string;
|
||||
model: string;
|
||||
task: "ocr" | "document_parsing";
|
||||
pageRanges?: string;
|
||||
batchId?: string;
|
||||
}
|
||||
|
||||
export interface JobStatus {
|
||||
jobId: string;
|
||||
state: "pending" | "running" | "done" | "failed";
|
||||
progress?: Progress;
|
||||
resultUrl?: Record<string, string>;
|
||||
errorMsg?: string;
|
||||
}
|
||||
|
||||
export interface BatchStatus {
|
||||
batchId: string;
|
||||
jobs: JobStatus[];
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
APIError,
|
||||
AuthError,
|
||||
FileNotFoundError,
|
||||
InvalidRequestError,
|
||||
JobFailedError,
|
||||
Model,
|
||||
PaddleOCRClient,
|
||||
PollTimeoutError,
|
||||
RequestTimeoutError,
|
||||
ResponseFormatError,
|
||||
ResultParseError,
|
||||
} from "../src/index.js";
|
||||
import type { DocParsingResult, Job, OCRResult } from "../src/index.js";
|
||||
|
||||
type FetchCall = {
|
||||
url: string;
|
||||
init: RequestInit;
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function textResponse(body: string, status = 200): Response {
|
||||
return new Response(body, { status });
|
||||
}
|
||||
|
||||
function createClient(fetchImpl: typeof fetch, options: Partial<ConstructorParameters<typeof PaddleOCRClient>[0]> = {}) {
|
||||
return new PaddleOCRClient({
|
||||
token: "test-token",
|
||||
baseUrl: "https://api.example.test",
|
||||
requestTimeout: 100,
|
||||
pollTimeout: 100,
|
||||
fetch: fetchImpl,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function captureFetch(responses: Array<Response | Error | Promise<Response>>): { fetch: typeof fetch; calls: FetchCall[] } {
|
||||
const calls: FetchCall[] = [];
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push({ url: String(input), init: init ?? {} });
|
||||
const next = responses.shift();
|
||||
if (next instanceof Error) {
|
||||
throw next;
|
||||
}
|
||||
if (!next) {
|
||||
throw new Error("No mocked response remaining");
|
||||
}
|
||||
return next;
|
||||
}) as unknown as typeof fetch;
|
||||
return { fetch: fetchImpl, calls };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("PaddleOCRClient public contract", () => {
|
||||
test("requires token at construction and reads PADDLEOCR_ACCESS_TOKEN fallback", () => {
|
||||
const previous = process.env.PADDLEOCR_ACCESS_TOKEN;
|
||||
delete process.env.PADDLEOCR_ACCESS_TOKEN;
|
||||
expect(() => new PaddleOCRClient()).toThrow(AuthError);
|
||||
|
||||
process.env.PADDLEOCR_ACCESS_TOKEN = "env-token";
|
||||
const { fetch } = captureFetch([jsonResponse({ data: { state: "running" } })]);
|
||||
expect(() => new PaddleOCRClient({ baseUrl: "https://api.example.test", fetch })).not.toThrow();
|
||||
|
||||
if (previous === undefined) {
|
||||
delete process.env.PADDLEOCR_ACCESS_TOKEN;
|
||||
} else {
|
||||
process.env.PADDLEOCR_ACCESS_TOKEN = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test("exposes contract names and omits unpublished draft aliases", () => {
|
||||
const { fetch } = captureFetch([]);
|
||||
const client = createClient(fetch);
|
||||
expect(client.getStatus).toBeTypeOf("function");
|
||||
expect(client.parseDocument).toBeTypeOf("function");
|
||||
expect(client.submitDocumentParsing).toBeTypeOf("function");
|
||||
expect(client.waitOcrResult).toBeTypeOf("function");
|
||||
expect(client.waitDocumentParsingResult).toBeTypeOf("function");
|
||||
expect(client.saveResource).toBeTypeOf("function");
|
||||
expect(client.saveOcrResultResources).toBeTypeOf("function");
|
||||
expect(client.saveDocumentParsingResultResources).toBeTypeOf("function");
|
||||
|
||||
expect("getResult" in client).toBe(false);
|
||||
expect("waitForResult" in client).toBe(false);
|
||||
expect("docParsing" in client).toBe(false);
|
||||
expect("submitDocParsing" in client).toBe(false);
|
||||
});
|
||||
|
||||
test("submitOcr sends contract body and returns job metadata", async () => {
|
||||
const { fetch, calls } = captureFetch([jsonResponse({ data: { jobId: "job-1" } })]);
|
||||
const client = createClient(fetch);
|
||||
|
||||
const job = await client.submitOcr({
|
||||
fileUrl: "https://files.example.test/invoice.pdf",
|
||||
pageRanges: "1-2",
|
||||
batchId: "batch-1",
|
||||
options: { visualize: true },
|
||||
});
|
||||
|
||||
expect(job).toEqual({
|
||||
jobId: "job-1",
|
||||
model: Model.PPOCRv6,
|
||||
task: "ocr",
|
||||
pageRanges: "1-2",
|
||||
batchId: "batch-1",
|
||||
});
|
||||
expect(calls[0].url).toBe("https://api.example.test/api/v2/ocr/jobs");
|
||||
expect(calls[0].init.method).toBe("POST");
|
||||
expect(calls[0].init.headers).toMatchObject({
|
||||
Authorization: "Bearer test-token",
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(JSON.parse(String(calls[0].init.body))).toEqual({
|
||||
fileUrl: "https://files.example.test/invoice.pdf",
|
||||
model: Model.PPOCRv6,
|
||||
optionalPayload: { visualize: true },
|
||||
pageRanges: "1-2",
|
||||
batchId: "batch-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("clientPlatform is sent as Client-Platform header on API requests", async () => {
|
||||
const { fetch, calls } = captureFetch([jsonResponse({ data: { jobId: "job-1" } })]);
|
||||
const client = createClient(fetch, { clientPlatform: "my-app" });
|
||||
|
||||
await client.submitOcr({
|
||||
fileUrl: "https://files.example.test/invoice.pdf",
|
||||
});
|
||||
|
||||
expect(calls[0].init.headers).toMatchObject({
|
||||
"Client-Platform": "my-app",
|
||||
});
|
||||
});
|
||||
|
||||
test("submitOcr propagates explicit OCR model and defaults to PP-OCRv6", async () => {
|
||||
const { fetch, calls } = captureFetch([
|
||||
jsonResponse({ data: { jobId: "job-explicit" } }),
|
||||
jsonResponse({ data: { jobId: "job-default" } }),
|
||||
]);
|
||||
const client = createClient(fetch);
|
||||
|
||||
const explicit = await client.submitOcr({
|
||||
model: Model.PPOCRv5,
|
||||
fileUrl: "https://files.example.test/explicit.pdf",
|
||||
});
|
||||
const implicit = await client.submitOcr({
|
||||
fileUrl: "https://files.example.test/default.pdf",
|
||||
});
|
||||
|
||||
expect(explicit.model).toBe(Model.PPOCRv5);
|
||||
expect(implicit.model).toBe(Model.PPOCRv6);
|
||||
expect(JSON.parse(String(calls[0].init.body)).model).toBe(Model.PPOCRv5);
|
||||
expect(JSON.parse(String(calls[1].init.body)).model).toBe(Model.PPOCRv6);
|
||||
});
|
||||
|
||||
test("submitOcr accepts PP-OCRv6 model name", async () => {
|
||||
const { fetch, calls } = captureFetch([jsonResponse({ data: { jobId: "job-v6" } })]);
|
||||
const client = createClient(fetch);
|
||||
|
||||
const job = await client.submitOcr({
|
||||
model: Model.PPOCRv6,
|
||||
fileUrl: "https://files.example.test/v6.pdf",
|
||||
});
|
||||
|
||||
expect(job.model).toBe(Model.PPOCRv6);
|
||||
expect(JSON.parse(String(calls[0].init.body)).model).toBe(Model.PPOCRv6);
|
||||
});
|
||||
|
||||
test("submitOcr accepts PP-OCRv5-latin model name", async () => {
|
||||
const { fetch, calls } = captureFetch([jsonResponse({ data: { jobId: "job-latin" } })]);
|
||||
const client = createClient(fetch);
|
||||
|
||||
const job = await client.submitOcr({
|
||||
model: Model.PPOCRv5Latin,
|
||||
fileUrl: "https://files.example.test/latin.pdf",
|
||||
});
|
||||
|
||||
expect(job.model).toBe(Model.PPOCRv5Latin);
|
||||
expect(JSON.parse(String(calls[0].init.body)).model).toBe(Model.PPOCRv5Latin);
|
||||
});
|
||||
|
||||
test("submitOcr and submitDocumentParsing accept official model name strings", async () => {
|
||||
const { fetch, calls } = captureFetch([
|
||||
jsonResponse({ data: { jobId: "job-ocr" } }),
|
||||
jsonResponse({ data: { jobId: "job-doc" } }),
|
||||
]);
|
||||
const client = createClient(fetch);
|
||||
|
||||
const ocrJob = await client.submitOcr({
|
||||
model: "PP-OCRv5",
|
||||
fileUrl: "https://files.example.test/ocr.pdf",
|
||||
});
|
||||
const docJob = await client.submitDocumentParsing({
|
||||
model: "PaddleOCR-VL-1.6",
|
||||
fileUrl: "https://files.example.test/doc.pdf",
|
||||
});
|
||||
|
||||
expect(ocrJob.model).toBe("PP-OCRv5");
|
||||
expect(docJob.model).toBe("PaddleOCR-VL-1.6");
|
||||
expect(JSON.parse(String(calls[0].init.body)).model).toBe("PP-OCRv5");
|
||||
expect(JSON.parse(String(calls[1].init.body)).model).toBe("PaddleOCR-VL-1.6");
|
||||
});
|
||||
|
||||
test("model helpers classify current OCR and document parsing models", async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
|
||||
expect(mod.isOCRModel(Model.PPOCRv5)).toBe(true);
|
||||
expect(mod.isOCRModel(Model.PPOCRv5Latin)).toBe(true);
|
||||
expect(mod.isOCRModel(Model.PPOCRv6)).toBe(true);
|
||||
expect(mod.isOCRModel(Model.PPStructureV3)).toBe(false);
|
||||
expect(mod.isOCRModel("future-unknown-model")).toBe(false);
|
||||
expect(mod.isDocumentParsingModel(Model.PaddleOCRVL)).toBe(true);
|
||||
expect(mod.isDocumentParsingModel(Model.PaddleOCRVL16)).toBe(true);
|
||||
expect(mod.isDocumentParsingModel(Model.PPOCRv5)).toBe(false);
|
||||
});
|
||||
|
||||
test("submitDocumentParsing passes current and future document parsing options", async () => {
|
||||
const { fetch, calls } = captureFetch([jsonResponse({ data: { jobId: "job-doc" } })]);
|
||||
const client = createClient(fetch);
|
||||
|
||||
await client.submitDocumentParsing({
|
||||
model: Model.PaddleOCRVL16,
|
||||
fileUrl: "https://files.example.test/doc.pdf",
|
||||
options: {
|
||||
useOcrForImageBlock: true,
|
||||
formatBlockContent: true,
|
||||
markdownIgnoreLabels: ["image"],
|
||||
vlmExtraArgs: { temperature: 0.1 },
|
||||
returnMarkdownImages: false,
|
||||
outputFormats: ["docx"],
|
||||
futureOption: "enabled",
|
||||
},
|
||||
});
|
||||
|
||||
expect(JSON.parse(String(calls[0].init.body)).optionalPayload).toEqual({
|
||||
useOcrForImageBlock: true,
|
||||
formatBlockContent: true,
|
||||
markdownIgnoreLabels: ["image"],
|
||||
vlmExtraArgs: { temperature: 0.1 },
|
||||
returnMarkdownImages: false,
|
||||
outputFormats: ["docx"],
|
||||
futureOption: "enabled",
|
||||
});
|
||||
});
|
||||
|
||||
test("submitDocumentParsing defaults to PaddleOCR-VL-1.6", async () => {
|
||||
const { fetch, calls } = captureFetch([jsonResponse({ data: { jobId: "job-doc" } })]);
|
||||
const client = createClient(fetch);
|
||||
|
||||
const job = await client.submitDocumentParsing({
|
||||
fileUrl: "https://files.example.test/doc.pdf",
|
||||
});
|
||||
|
||||
expect(job.model).toBe(Model.PaddleOCRVL16);
|
||||
expect(JSON.parse(String(calls[0].init.body)).model).toBe(Model.PaddleOCRVL16);
|
||||
});
|
||||
|
||||
test("submitOcr rejects non-OCR models before network calls", async () => {
|
||||
const { fetch } = captureFetch([]);
|
||||
const client = createClient(fetch);
|
||||
|
||||
await expect(
|
||||
client.submitOcr({
|
||||
model: Model.PPStructureV3,
|
||||
fileUrl: "https://files.example.test/not-ocr.pdf",
|
||||
}),
|
||||
).rejects.toThrow(InvalidRequestError);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("validates submit requests before network calls", async () => {
|
||||
const { fetch } = captureFetch([]);
|
||||
const client = createClient(fetch);
|
||||
|
||||
await expect(client.submitOcr({})).rejects.toThrow(InvalidRequestError);
|
||||
await expect(
|
||||
client.submitOcr({ fileUrl: "https://files.example.test/a.pdf", filePath: "./a.pdf" }),
|
||||
).rejects.toThrow(InvalidRequestError);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("getStatus performs one non-blocking status request", async () => {
|
||||
const { fetch, calls } = captureFetch([
|
||||
jsonResponse({
|
||||
data: {
|
||||
state: "running",
|
||||
extractProgress: { totalPages: 3, extractedPages: 1 },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const client = createClient(fetch);
|
||||
|
||||
await expect(client.getStatus("job-1")).resolves.toEqual({
|
||||
jobId: "job-1",
|
||||
state: "running",
|
||||
progress: { totalPages: 3, extractedPages: 1 },
|
||||
errorMsg: undefined,
|
||||
});
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url).toBe("https://api.example.test/api/v2/ocr/jobs/job-1");
|
||||
});
|
||||
|
||||
test("normalizes multiple trailing baseUrl slashes", async () => {
|
||||
const { fetch, calls } = captureFetch([jsonResponse({ data: { state: "running" } })]);
|
||||
const client = createClient(fetch, { baseUrl: "https://api.example.test///" });
|
||||
|
||||
await client.getStatus("job-1");
|
||||
|
||||
expect(calls[0].url).toBe("https://api.example.test/api/v2/ocr/jobs/job-1");
|
||||
});
|
||||
|
||||
test("typed wait accepts bare jobId, fetches JSONL without Authorization, and parses OCR results", async () => {
|
||||
const { fetch, calls } = captureFetch([
|
||||
jsonResponse({ data: { state: "done", resultUrl: { jsonUrl: "https://storage.example.test/job-1.jsonl" } } }),
|
||||
textResponse(
|
||||
JSON.stringify({
|
||||
result: {
|
||||
dataInfo: { numPages: 1 },
|
||||
ocrResults: [
|
||||
{
|
||||
prunedResult: { text: "hello" },
|
||||
ocrImage: "img.png",
|
||||
docPreprocessingImage: "pre.png",
|
||||
inputImage: "input.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
]);
|
||||
const client = createClient(fetch);
|
||||
|
||||
await expect(client.waitOcrResult("job-1")).resolves.toMatchObject({
|
||||
jobId: "job-1",
|
||||
dataInfo: { numPages: 1 },
|
||||
pages: [
|
||||
{
|
||||
prunedResult: { text: "hello" },
|
||||
ocrImageUrl: "img.png",
|
||||
docPreprocessingImageUrl: "pre.png",
|
||||
inputImageUrl: "input.png",
|
||||
raw: {
|
||||
prunedResult: { text: "hello" },
|
||||
ocrImage: "img.png",
|
||||
docPreprocessingImage: "pre.png",
|
||||
inputImage: "input.png",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(calls[1].url).toBe("https://storage.example.test/job-1.jsonl");
|
||||
expect((calls[1].init.headers as Record<string, string>).Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
test("typed waits reject mismatched Job task before polling", async () => {
|
||||
const { fetch } = captureFetch([]);
|
||||
const client = createClient(fetch);
|
||||
const docJob: Job = { jobId: "job-1", model: Model.PPStructureV3, task: "document_parsing" };
|
||||
|
||||
await expect(client.waitOcrResult(docJob)).rejects.toThrow(InvalidRequestError);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("typed waits reject mismatched Job model before polling", async () => {
|
||||
const { fetch } = captureFetch([]);
|
||||
const client = createClient(fetch);
|
||||
const ocrTaskWithDocumentModel: Job = { jobId: "job-1", model: Model.PPStructureV3, task: "ocr" };
|
||||
const documentTaskWithOcrModel: Job = { jobId: "job-2", model: Model.PPOCRv5, task: "document_parsing" };
|
||||
|
||||
await expect(client.waitOcrResult(ocrTaskWithDocumentModel)).rejects.toThrow(InvalidRequestError);
|
||||
await expect(client.waitDocumentParsingResult(documentTaskWithOcrModel)).rejects.toThrow(InvalidRequestError);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("polling maps failed, timeout, unknown state, and missing result URL", async () => {
|
||||
const failed = createClient(captureFetch([jsonResponse({ data: { state: "failed", errorMsg: "boom" } })]).fetch);
|
||||
await expect(failed.waitOcrResult("job-1")).rejects.toThrow(JobFailedError);
|
||||
|
||||
const unknown = createClient(captureFetch([jsonResponse({ data: { state: "paused" } })]).fetch);
|
||||
await expect(unknown.waitOcrResult("job-1")).rejects.toThrow(ResponseFormatError);
|
||||
|
||||
const missingResult = createClient(captureFetch([jsonResponse({ data: { state: "done" } })]).fetch);
|
||||
await expect(missingResult.waitOcrResult("job-1")).rejects.toThrow(ResponseFormatError);
|
||||
|
||||
vi.useFakeTimers();
|
||||
const timeoutFetch = vi.fn(async () => jsonResponse({ data: { state: "running" } })) as unknown as typeof fetch;
|
||||
const timeout = createClient(timeoutFetch, { pollTimeout: 1 });
|
||||
const promise = expect(timeout.waitOcrResult("job-1")).rejects.toThrow(PollTimeoutError);
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
});
|
||||
|
||||
test("malformed successful responses and malformed JSONL use dedicated errors", async () => {
|
||||
const malformedSubmit = createClient(captureFetch([jsonResponse({ data: {} })]).fetch);
|
||||
await expect(malformedSubmit.submitOcr({ fileUrl: "https://files.example.test/a.pdf" })).rejects.toThrow(
|
||||
ResponseFormatError,
|
||||
);
|
||||
|
||||
const malformedJsonl = createClient(
|
||||
captureFetch([
|
||||
jsonResponse({ data: { state: "done", resultUrl: { jsonUrl: "https://storage.example.test/job.jsonl" } } }),
|
||||
textResponse("{not-json}\n"),
|
||||
]).fetch,
|
||||
);
|
||||
await expect(malformedJsonl.waitOcrResult("job-1")).rejects.toThrow(ResultParseError);
|
||||
});
|
||||
|
||||
test("malformed fetched OCR result records use ResultParseError", async () => {
|
||||
const client = createClient(
|
||||
captureFetch([
|
||||
jsonResponse({ data: { state: "done", resultUrl: { jsonUrl: "https://storage.example.test/job.jsonl" } } }),
|
||||
textResponse(JSON.stringify({ notResult: {} })),
|
||||
]).fetch,
|
||||
);
|
||||
|
||||
await expect(client.waitOcrResult("job-1")).rejects.toThrow(ResultParseError);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["missing ocrResults", { result: {} }],
|
||||
["missing prunedResult", { result: { ocrResults: [{ ocrImage: "img.png" }] } }],
|
||||
])("malformed OCR payload with %s uses ResultParseError", async (_name, payload) => {
|
||||
const client = createClient(
|
||||
captureFetch([
|
||||
jsonResponse({ data: { state: "done", resultUrl: { jsonUrl: "https://storage.example.test/job.jsonl" } } }),
|
||||
textResponse(JSON.stringify(payload)),
|
||||
]).fetch,
|
||||
);
|
||||
|
||||
await expect(client.waitOcrResult("job-1")).rejects.toThrow(ResultParseError);
|
||||
});
|
||||
|
||||
test("waitDocumentParsingResult preserves raw page data and dataInfo", async () => {
|
||||
const page = {
|
||||
prunedResult: { blocks: [{ label: "text", content: "hello" }] },
|
||||
markdown: { text: "hello", images: { "figure.png": "https://example.test/figure.png" }, isStart: true, isEnd: true },
|
||||
outputImages: { "page.png": "https://example.test/page.png" },
|
||||
inputImage: "https://example.test/input.png",
|
||||
exports: { docx: "https://example.test/result.docx" },
|
||||
};
|
||||
const { fetch } = captureFetch([
|
||||
jsonResponse({ data: { state: "done", resultUrl: { jsonUrl: "https://storage.example.test/job.jsonl" } } }),
|
||||
textResponse(JSON.stringify({ result: { dataInfo: { numPages: 1 }, layoutParsingResults: [page] } })),
|
||||
]);
|
||||
const client = createClient(fetch);
|
||||
|
||||
await expect(client.waitDocumentParsingResult("job-1")).resolves.toMatchObject({
|
||||
jobId: "job-1",
|
||||
dataInfo: { numPages: 1 },
|
||||
pages: [
|
||||
{
|
||||
markdownText: "hello",
|
||||
markdownImages: { "figure.png": "https://example.test/figure.png" },
|
||||
outputImages: { "page.png": "https://example.test/page.png" },
|
||||
prunedResult: { blocks: [{ label: "text", content: "hello" }] },
|
||||
inputImageUrl: "https://example.test/input.png",
|
||||
exports: { docx: "https://example.test/result.docx" },
|
||||
markdown: page.markdown,
|
||||
raw: page,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("malformed fetched document result records use ResultParseError", async () => {
|
||||
const client = createClient(
|
||||
captureFetch([
|
||||
jsonResponse({ data: { state: "done", resultUrl: { jsonUrl: "https://storage.example.test/job.jsonl" } } }),
|
||||
textResponse(JSON.stringify({ notResult: {} })),
|
||||
]).fetch,
|
||||
);
|
||||
|
||||
await expect(client.waitDocumentParsingResult("job-1")).rejects.toThrow(ResultParseError);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["missing layoutParsingResults", { result: {} }],
|
||||
["missing markdown", { result: { layoutParsingResults: [{}] } }],
|
||||
["missing markdown.text", { result: { layoutParsingResults: [{ markdown: { images: {} } }] } }],
|
||||
])("malformed document payload with %s uses ResultParseError", async (_name, payload) => {
|
||||
const client = createClient(
|
||||
captureFetch([
|
||||
jsonResponse({ data: { state: "done", resultUrl: { jsonUrl: "https://storage.example.test/job.jsonl" } } }),
|
||||
textResponse(JSON.stringify(payload)),
|
||||
]).fetch,
|
||||
);
|
||||
|
||||
await expect(client.waitDocumentParsingResult("job-1")).rejects.toThrow(ResultParseError);
|
||||
});
|
||||
|
||||
test("HTTP status and timeout failures map to contract errors", async () => {
|
||||
await expect(createClient(captureFetch([textResponse("bad auth", 401)]).fetch).getStatus("job-1")).rejects.toThrow(
|
||||
AuthError,
|
||||
);
|
||||
await expect(createClient(captureFetch([textResponse("bad request", 400)]).fetch).getStatus("job-1")).rejects.toThrow(
|
||||
InvalidRequestError,
|
||||
);
|
||||
await expect(createClient(captureFetch([textResponse("server error", 500)]).fetch).getStatus("job-1")).rejects.toThrow(
|
||||
APIError,
|
||||
);
|
||||
|
||||
const timeoutFetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
|
||||
}),
|
||||
) as unknown as typeof fetch;
|
||||
await expect(createClient(timeoutFetch, { requestTimeout: 1 }).getStatus("job-1")).rejects.toThrow(
|
||||
RequestTimeoutError,
|
||||
);
|
||||
});
|
||||
|
||||
test("submitFile reports missing local files before network calls", async () => {
|
||||
const { fetch } = captureFetch([]);
|
||||
const client = createClient(fetch);
|
||||
|
||||
await expect(client.submitOcr({ filePath: "/definitely/missing/file.pdf" })).rejects.toThrow(FileNotFoundError);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("saveResource writes URL downloads without auth and requires overwrite opt-in", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "paddleocr-api-sdk-"));
|
||||
try {
|
||||
const { fetch, calls } = captureFetch([textResponse("content"), textResponse("replacement")]);
|
||||
const client = createClient(fetch);
|
||||
|
||||
const saved = await client.saveResource("https://storage.example.test/a/b/c.png", dir);
|
||||
expect(saved).toBe(join(dir, "c.png"));
|
||||
await expect(readFile(join(dir, "c.png"), "utf8")).resolves.toBe("content");
|
||||
expect((calls[0].init.headers as Record<string, string>).Authorization).toBeUndefined();
|
||||
|
||||
await expect(client.saveResource("https://storage.example.test/a/b/c.png", dir)).rejects.toThrow(
|
||||
InvalidRequestError,
|
||||
);
|
||||
|
||||
await client.saveResource("https://storage.example.test/a/b/c.png", dir, { overwrite: true });
|
||||
await expect(readFile(join(dir, "c.png"), "utf8")).resolves.toBe("replacement");
|
||||
await expect(stat(join(dir, "c.png"))).resolves.toBeTruthy();
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("saveOcrResultResources saves OCR result image URLs with stable page filenames", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "paddleocr-api-sdk-"));
|
||||
try {
|
||||
const { fetch, calls } = captureFetch([textResponse("page 1"), textResponse("page 2")]);
|
||||
const client = createClient(fetch);
|
||||
const result: OCRResult = {
|
||||
jobId: "job-ocr",
|
||||
pages: [
|
||||
{ prunedResult: { text: "one" }, ocrImageUrl: "https://storage.example.test/results/page-a.png?sig=opaque" },
|
||||
{ prunedResult: { text: "two" }, ocrImageUrl: "https://storage.example.test/results/page-b.jpg" },
|
||||
],
|
||||
};
|
||||
|
||||
const saved = await client.saveOcrResultResources(result, dir);
|
||||
|
||||
expect(saved).toEqual([join(dir, "ocr-page-1.png"), join(dir, "ocr-page-2.jpg")]);
|
||||
await expect(readFile(join(dir, "ocr-page-1.png"), "utf8")).resolves.toBe("page 1");
|
||||
await expect(readFile(join(dir, "ocr-page-2.jpg"), "utf8")).resolves.toBe("page 2");
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls.map((call) => (call.init.headers as Record<string, string>).Authorization)).toEqual([
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("saveDocumentParsingResultResources preserves safe document parsing map keys over opaque URL basenames", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "paddleocr-api-sdk-"));
|
||||
try {
|
||||
const { fetch } = captureFetch([textResponse("markdown image"), textResponse("output image")]);
|
||||
const client = createClient(fetch);
|
||||
const result: DocParsingResult = {
|
||||
jobId: "job-doc",
|
||||
pages: [
|
||||
{
|
||||
markdownText: "",
|
||||
markdownImages: {
|
||||
"figure 1.png": "https://storage.example.test/download?id=markdown-image",
|
||||
},
|
||||
outputImages: {
|
||||
"rendered-page.jpg": "https://storage.example.test/blob?id=output-image",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const saved = await client.saveDocumentParsingResultResources(result, dir);
|
||||
|
||||
expect(saved).toEqual([join(dir, "figure 1.png"), join(dir, "rendered-page.jpg")]);
|
||||
await expect(readFile(join(dir, "figure 1.png"), "utf8")).resolves.toBe("markdown image");
|
||||
await expect(readFile(join(dir, "rendered-page.jpg"), "utf8")).resolves.toBe("output image");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("saveDocumentParsingResultResources sorts document parsing map keys for deterministic output", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "paddleocr-api-sdk-"));
|
||||
try {
|
||||
const { fetch, calls } = captureFetch([textResponse("alpha"), textResponse("bravo"), textResponse("charlie")]);
|
||||
const client = createClient(fetch);
|
||||
const result: DocParsingResult = {
|
||||
jobId: "job-doc",
|
||||
pages: [
|
||||
{
|
||||
markdownText: "",
|
||||
markdownImages: {
|
||||
"charlie.png": "https://storage.example.test/charlie",
|
||||
"alpha.png": "https://storage.example.test/alpha",
|
||||
"bravo.png": "https://storage.example.test/bravo",
|
||||
},
|
||||
outputImages: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const saved = await client.saveDocumentParsingResultResources(result, dir);
|
||||
|
||||
expect(saved).toEqual([join(dir, "alpha.png"), join(dir, "bravo.png"), join(dir, "charlie.png")]);
|
||||
expect(calls.map((call) => call.url)).toEqual([
|
||||
"https://storage.example.test/alpha",
|
||||
"https://storage.example.test/bravo",
|
||||
"https://storage.example.test/charlie",
|
||||
]);
|
||||
await expect(readFile(join(dir, "alpha.png"), "utf8")).resolves.toBe("alpha");
|
||||
await expect(readFile(join(dir, "bravo.png"), "utf8")).resolves.toBe("bravo");
|
||||
await expect(readFile(join(dir, "charlie.png"), "utf8")).resolves.toBe("charlie");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("saveOcrResultResources requires overwrite opt-in for bulk result files", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "paddleocr-api-sdk-"));
|
||||
try {
|
||||
const result: OCRResult = {
|
||||
jobId: "job-ocr",
|
||||
pages: [{ prunedResult: {}, ocrImageUrl: "https://storage.example.test/page.png" }],
|
||||
};
|
||||
|
||||
await writeFile(join(dir, "ocr-page-1.png"), "existing");
|
||||
const blocked = captureFetch([textResponse("unexpected")]);
|
||||
await expect(createClient(blocked.fetch).saveOcrResultResources(result, dir)).rejects.toThrow(InvalidRequestError);
|
||||
expect(blocked.calls).toHaveLength(0);
|
||||
|
||||
const replacement = captureFetch([textResponse("replacement")]);
|
||||
await createClient(replacement.fetch).saveOcrResultResources(result, dir, { overwrite: true });
|
||||
await expect(readFile(join(dir, "ocr-page-1.png"), "utf8")).resolves.toBe("replacement");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
["empty", ""],
|
||||
["absolute", "/absolute.png"],
|
||||
["parent traversal", "../escape.png"],
|
||||
["traversal segment", ".."],
|
||||
["forward separator", "nested/escape.png"],
|
||||
["backslash separator", "nested\\escape.png"],
|
||||
])("saveDocumentParsingResultResources rejects unsafe document parsing map key: %s", async (_name, unsafeKey) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "paddleocr-api-sdk-"));
|
||||
try {
|
||||
const { fetch } = captureFetch([]);
|
||||
const client = createClient(fetch);
|
||||
const result: DocParsingResult = {
|
||||
jobId: "job-doc",
|
||||
pages: [
|
||||
{
|
||||
markdownText: "",
|
||||
markdownImages: {
|
||||
[unsafeKey]: "https://storage.example.test/escape.png",
|
||||
},
|
||||
outputImages: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(client.saveDocumentParsingResultResources(result, dir)).rejects.toThrow(InvalidRequestError);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"],
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@paddleocr/api-sdk": ["src/index.ts"]
|
||||
},
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "tests", "examples"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm", "cjs"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
splitting: false,
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
移步[docs](https://www.paddleocr.ai/v2.10.0/applications/overview.html)
|
||||
@@ -0,0 +1,30 @@
|
||||
## 😃 Awesome projects based on PaddleOCR
|
||||
💗 PaddleOCR wouldn’t be where it is today without its incredible community! A massive 🙌 thank you 🙌 to all our longtime partners, new collaborators, and everyone who’s poured their passion into PaddleOCR — whether we’ve named you or not. Your support fuels our fire! 🔥
|
||||
| Project Name | Description |
|
||||
| ------------ | ----------- |
|
||||
| [Umi-OCR](https://github.com/hiroi-sora/Umi-OCR) <a href="https://github.com/hiroi-sora/Umi-OCR"><img src="https://img.shields.io/github/stars/hiroi-sora/Umi-OCR"></a>|Free, Open-source, Batch Offline OCR Software.|
|
||||
| [LearnOpenCV](http://github.com/spmallick/learnopencv) <a href="http://github.com/spmallick/learnopencv"><img src="https://img.shields.io/github/stars/spmallick/learnopencv"></a> | code for Computer Vision, Deep learning, and AI research articles.|
|
||||
| [OmniParser](https://github.com/microsoft/OmniParser)<a href="https://github.com/microsoft/OmniParser"><img src="https://img.shields.io/github/stars/microsoft/OmniParser"></a> |OmniParser: Screen Parsing tool for Pure Vision Based GUI Agent.|
|
||||
| [QAnything](https://github.com/netease-youdao/QAnything)<a href="https://github.com/netease-youdao/QAnything"><img src="https://img.shields.io/github/stars/netease-youdao/QAnything"></a> |Question and Answer based on Anything.|
|
||||
| [PaddleHub](https://github.com/PaddlePaddle/PaddleHub)<a href="https://github.com/PaddlePaddle/PaddleHub"><img src="https://img.shields.io/github/stars/PaddlePaddle/PaddleHub"></a> |400+ AI Models: Rich, high-quality AI models, including CV, NLP, Speech, Video and Cross-Modal.|
|
||||
| [PaddleNLP](https://github.com/PaddlePaddle/PaddleNLP)<a href="https://github.com/PaddlePaddle/PaddleNLP"><img src="https://img.shields.io/github/stars/PaddlePaddle/PaddleNLP"></a> |A Large Language Model (LLM) development suite based on the PaddlePaddle.|
|
||||
| [Rerun](https://github.com/rerun-io/rerun) <a href="https://github.com/rerun-io/rerun"><img src="https://img.shields.io/github/stars/rerun-io/rerun"></a> | Rerun is building the multimodal data stack to model, ingest, store, query and view robotics-style data |
|
||||
| [Dango-Translator](https://github.com/PantsuDango/Dango-Translator) <a href="https://github.com/PantsuDango/Dango-Translator"><img src="https://img.shields.io/github/stars/PantsuDango/Dango-Translator"></a> | Recognize text on the screen, translate it and show the translation results in real time.|
|
||||
| [PDF-Extract-Kit](https://github.com/opendatalab/PDF-Extract-Kit) <a href="https://github.com/opendatalab/PDF-Extract-Kit"><img src="https://img.shields.io/github/stars/opendatalab/PDF-Extract-Kit"></a> | PDF-Extract-Kit is a powerful open-source toolkit designed to efficiently extract high-quality content from complex and diverse PDF documents. |
|
||||
| [manga-image-translator](https://github.com/zyddnys/manga-image-translator) <a href="https://github.com/zyddnys/manga-image-translator"><img src="https://img.shields.io/github/stars/zyddnys/manga-image-translator"></a> | Translate texts in manga/images.|
|
||||
| [March7thAssistant](https://github.com/moesnow/March7thAssistant) <a href="https://github.com/moesnow/March7thAssistant"><img src="https://img.shields.io/github/stars/moesnow/March7thAssistant"></a> | Daily Tasks: Stamina recovery, daily training, claiming rewards, commissions, and farming. |
|
||||
| [PaddlePaddle/models](https://github.com/PaddlePaddle/models) <a href="https://github.com/PaddlePaddle/models"><img src="https://img.shields.io/github/stars/PaddlePaddle/models"></a> |PaddlePaddle's industrial-grade model zoo.|
|
||||
| [katanaml/sparrow](https://github.com/katanaml/sparrow) <a href="https://github.com/katanaml/sparrow"><img src="https://img.shields.io/github/stars/katanaml/sparrow"></a> | Sparrow is an innovative open-source solution for efficient data extraction and processing from various documents and images. |
|
||||
| [RapidOCR](https://github.com/RapidAI/RapidOCR) <a href="https://github.com/RapidAI/RapidOCR"><img src="https://img.shields.io/github/stars/RapidAI/RapidOCR"></a> | Awesome OCR multiple programming languages toolkits based on ONNXRuntime, OpenVINO, PaddlePaddle and PyTorch |
|
||||
| [autoMate](https://github.com/yuruotong1/autoMate) <a href="https://github.com/yuruotong1/autoMate"><img src="https://img.shields.io/github/stars/yuruotong1/autoMate"></a> | AI-Powered Local Automation Tool & Let Your Computer Work for You. |
|
||||
| [Agent-S](https://github.com/simular-ai/Agent-S) <a href="https://github.com/simular-ai/Agent-S"><img src="https://img.shields.io/github/stars/simular-ai/Agent-S"></a> | A Compositional Generalist-Specialist Framework for Computer Use Agents. |
|
||||
| [pdf-craft](https://github.com/oomol-lab/pdf-craft) <a href="https://github.com/oomol-lab/pdf-craft"><img src="https://img.shields.io/github/stars/oomol-lab/pdf-craft"></a> | PDF Craft can convert PDF files into various other formats. |
|
||||
| [VV](https://github.com/Cicada000/VV) <a href="https://github.com/Cicada000/VV"><img src="https://img.shields.io/github/stars/Cicada000/VV"></a> | Zhang Weiwei Quotations Search Project. |
|
||||
| [docetl](https://github.com/ucbepic/docetl) <a href="https://github.com/ucbepic/docetl"><img src="https://img.shields.io/github/stars/ucbepic/docetl"></a> | DocETL is a tool for creating and executing data processing pipelines, especially suited for complex document processing tasks. |
|
||||
| [ZenlessZoneZero-Auto](https://github.com/sMythicalBird/ZenlessZoneZero-Auto) <a href="https://github.com/sMythicalBird/ZenlessZoneZero-Auto"><img src="https://img.shields.io/github/stars/sMythicalBird/ZenlessZoneZero-Auto"></a> | Zenless Zone Zero Automation Framework. |
|
||||
| [Yuxi-Know](https://github.com/xerrors/Yuxi-Know) <a href="https://github.com/xerrors/Yuxi-Know"><img src="https://img.shields.io/github/stars/xerrors/Yuxi-Know"></a> | Knowledge graph question answering system based on LLMs. |
|
||||
| [PaddleSharp](https://github.com/sdcb/PaddleSharp) <a href="https://github.com/sdcb/PaddleSharp"><img src="https://img.shields.io/github/stars/sdcb/PaddleSharp"></a>|.NET/C# binding for Baidu paddle inference library and PaddleOCR |
|
||||
| [python-office](https://github.com/CoderWanFeng/python-office) <a href="https://github.com/CoderWanFeng/python-office"><img src="https://img.shields.io/github/stars/CoderWanFeng/python-office"></a> | Python tool for office works. |
|
||||
| [OnnxOCR](https://github.com/jingsongliujing/OnnxOCR) <a href="https://github.com/jingsongliujing/OnnxOCR"><img src="https://img.shields.io/github/stars/jingsongliujing/OnnxOCR"></a>|A lightweight OCR system based on PaddleOCR, decoupled from the PaddlePaddle deep learning training framework, with ultra-fast inference speed |
|
||||
| [Frigate](https://github.com/blakeblackshear/frigate) <a href="https://github.com/blakeblackshear/frigate"><img src="https://img.shields.io/github/stars/blakeblackshear/frigate"></a> | Real-time NVR system with AI-powered object detection and License Plate Recognition (LPR) using PaddleOCR. |
|
||||
| ... |... |
|
||||
@@ -0,0 +1,2 @@
|
||||
*.html linguist-language=python
|
||||
*.ipynb linguist-language=python
|
||||
@@ -0,0 +1,16 @@
|
||||
.DS_Store
|
||||
*.pth
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.log
|
||||
*.tmp
|
||||
*.pkl
|
||||
__pycache__/
|
||||
.idea/
|
||||
output/
|
||||
test/*.jpg
|
||||
datasets/
|
||||
index/
|
||||
train_log/
|
||||
log/
|
||||
profiling_log/
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Real-time Scene Text Detection with Differentiable Binarization
|
||||
|
||||
**note**: some code is inherited from [WenmuZhou/DBNet.pytorch](https://github.com/WenmuZhou/DBNet.pytorch)
|
||||
|
||||
[中文解读](https://zhuanlan.zhihu.com/p/94677957)
|
||||
|
||||

|
||||
|
||||
## update
|
||||
2020-06-07: 添加灰度图训练,训练灰度图时需要在配置里移除`dataset.args.transforms.Normalize`
|
||||
|
||||
## Install Using Conda
|
||||
```
|
||||
conda env create -f environment.yml
|
||||
git clone https://github.com/WenmuZhou/DBNet.paddle.git
|
||||
cd DBNet.paddle/
|
||||
```
|
||||
|
||||
or
|
||||
## Install Manually
|
||||
```bash
|
||||
conda create -n dbnet python=3.6
|
||||
conda activate dbnet
|
||||
|
||||
conda install ipython pip
|
||||
|
||||
# python dependencies
|
||||
pip install -r requirement.txt
|
||||
|
||||
# clone repo
|
||||
git clone https://github.com/WenmuZhou/DBNet.paddle.git
|
||||
cd DBNet.paddle/
|
||||
|
||||
```
|
||||
|
||||
## Requirements
|
||||
* paddlepaddle 2.4+
|
||||
|
||||
## Download
|
||||
|
||||
TBD
|
||||
|
||||
## Data Preparation
|
||||
|
||||
Training data: prepare a text `train.txt` in the following format, use '\t' as a separator
|
||||
```
|
||||
./datasets/train/img/001.jpg ./datasets/train/gt/001.txt
|
||||
```
|
||||
|
||||
Validation data: prepare a text `test.txt` in the following format, use '\t' as a separator
|
||||
```
|
||||
./datasets/test/img/001.jpg ./datasets/test/gt/001.txt
|
||||
```
|
||||
- Store images in the `img` folder
|
||||
- Store groundtruth in the `gt` folder
|
||||
|
||||
The groundtruth can be `.txt` files, with the following format:
|
||||
```
|
||||
x1, y1, x2, y2, x3, y3, x4, y4, annotation
|
||||
```
|
||||
|
||||
|
||||
## Train
|
||||
1. config the `dataset['train']['dataset'['data_path']'`,`dataset['validate']['dataset'['data_path']`in [config/icdar2015_resnet18_fpn_DBhead_polyLR.yaml](cconfig/icdar2015_resnet18_fpn_DBhead_polyLR.yaml)
|
||||
* . single gpu train
|
||||
```bash
|
||||
bash single_gpu_train.sh
|
||||
```
|
||||
* . Multi-gpu training
|
||||
```bash
|
||||
bash multi_gpu_train.sh
|
||||
```
|
||||
## Test
|
||||
|
||||
[eval.py](tools/eval.py) is used to test model on test dataset
|
||||
|
||||
1. config `model_path` in [eval.sh](eval.sh)
|
||||
2. use following script to test
|
||||
```bash
|
||||
bash eval.sh
|
||||
```
|
||||
|
||||
## Predict
|
||||
[predict.py](tools/predict.py) Can be used to inference on all images in a folder
|
||||
1. config `model_path`,`input_folder`,`output_folder` in [predict.sh](predict.sh)
|
||||
2. use following script to predict
|
||||
```
|
||||
bash predict.sh
|
||||
```
|
||||
You can change the `model_path` in the `predict.sh` file to your model location.
|
||||
|
||||
tips: if result is not good, you can change `thre` in [predict.sh](predict.sh)
|
||||
|
||||
## Export Model
|
||||
|
||||
[export_model.py](tools/export_model.py) Can be used to inference on all images in a folder
|
||||
|
||||
use following script to export inference model
|
||||
```
|
||||
python tools/export_model.py --config_file config/icdar2015_resnet50_FPN_DBhead_polyLR.yaml -o trainer.resume_checkpoint=model_best.pth trainer.output_dir=output/infer
|
||||
```
|
||||
|
||||
## Paddle Inference infer
|
||||
|
||||
[infer.py](tools/infer.py) Can be used to inference on all images in a folder
|
||||
|
||||
use following script to export inference model
|
||||
```
|
||||
python tools/infer.py --model-dir=output/infer/ --img-path imgs/paper/db.jpg
|
||||
```
|
||||
|
||||
<h2 id="Performance">Performance</h2>
|
||||
|
||||
### [ICDAR 2015](http://rrc.cvc.uab.es/?ch=4)
|
||||
only train on ICDAR2015 dataset
|
||||
|
||||
| Method | image size (short size) |learning rate | Precision (%) | Recall (%) | F-measure (%) | FPS |
|
||||
|:--------------------------:|:-------:|:--------:|:--------:|:------------:|:---------------:|:-----:|
|
||||
| ImageNet-resnet50-FPN-DBHead(torch) |736 |1e-3|90.19 | 78.14 | 83.88 | 27 |
|
||||
| ImageNet-resnet50-FPN-DBHead(paddle) |736 |1e-3| 89.47 | 79.03 | 83.92 | 27 |
|
||||
| ImageNet-resnet50-FPN-DBHead(paddle_amp) |736 |1e-3| 88.62 | 79.95 | 84.06 | 27 |
|
||||
|
||||
|
||||
### examples
|
||||
TBD
|
||||
|
||||
|
||||
### reference
|
||||
1. https://arxiv.org/pdf/1911.08947.pdf
|
||||
2. https://github.com/WenmuZhou/DBNet.pytorch
|
||||
|
||||
**If this repository helps you,please star it. Thanks.**
|
||||
@@ -0,0 +1,2 @@
|
||||
from .base_trainer import BaseTrainer
|
||||
from .base_dataset import BaseDataSet
|
||||
@@ -0,0 +1,86 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# @Time : 2019/12/4 13:12
|
||||
# @Author : zhoujun
|
||||
import copy
|
||||
from paddle.io import Dataset
|
||||
from data_loader.modules import *
|
||||
|
||||
|
||||
class BaseDataSet(Dataset):
|
||||
def __init__(
|
||||
self,
|
||||
data_path: str,
|
||||
img_mode,
|
||||
pre_processes,
|
||||
filter_keys,
|
||||
ignore_tags,
|
||||
transform=None,
|
||||
target_transform=None,
|
||||
):
|
||||
assert img_mode in ["RGB", "BRG", "GRAY"]
|
||||
self.ignore_tags = ignore_tags
|
||||
self.data_list = self.load_data(data_path)
|
||||
item_keys = ["img_path", "img_name", "text_polys", "texts", "ignore_tags"]
|
||||
for item in item_keys:
|
||||
assert (
|
||||
item in self.data_list[0]
|
||||
), "data_list from load_data must contains {}".format(item_keys)
|
||||
self.img_mode = img_mode
|
||||
self.filter_keys = filter_keys
|
||||
self.transform = transform
|
||||
self.target_transform = target_transform
|
||||
self._init_pre_processes(pre_processes)
|
||||
|
||||
def _init_pre_processes(self, pre_processes):
|
||||
self.aug = []
|
||||
if pre_processes is not None:
|
||||
for aug in pre_processes:
|
||||
if "args" not in aug:
|
||||
args = {}
|
||||
else:
|
||||
args = aug["args"]
|
||||
if isinstance(args, dict):
|
||||
cls = eval(aug["type"])(**args)
|
||||
else:
|
||||
cls = eval(aug["type"])(args)
|
||||
self.aug.append(cls)
|
||||
|
||||
def load_data(self, data_path: str) -> list:
|
||||
"""
|
||||
把数据加载为一个list:
|
||||
:params data_path: 存储数据的文件夹或者文件
|
||||
return a dict ,包含了,'img_path','img_name','text_polys','texts','ignore_tags'
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def apply_pre_processes(self, data):
|
||||
for aug in self.aug:
|
||||
data = aug(data)
|
||||
return data
|
||||
|
||||
def __getitem__(self, index):
|
||||
try:
|
||||
data = copy.deepcopy(self.data_list[index])
|
||||
im = cv2.imread(data["img_path"], 1 if self.img_mode != "GRAY" else 0)
|
||||
if self.img_mode == "RGB":
|
||||
im = cv2.cvtColor(im, cv2.COLOR_BGR2RGB)
|
||||
data["img"] = im
|
||||
data["shape"] = [im.shape[0], im.shape[1]]
|
||||
data = self.apply_pre_processes(data)
|
||||
|
||||
if self.transform:
|
||||
data["img"] = self.transform(data["img"])
|
||||
data["text_polys"] = data["text_polys"].tolist()
|
||||
if len(self.filter_keys):
|
||||
data_dict = {}
|
||||
for k, v in data.items():
|
||||
if k not in self.filter_keys:
|
||||
data_dict[k] = v
|
||||
return data_dict
|
||||
else:
|
||||
return data
|
||||
except:
|
||||
return self.__getitem__(np.random.randint(self.__len__()))
|
||||
|
||||
def __len__(self):
|
||||
return len(self.data_list)
|
||||
@@ -0,0 +1,269 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# @Time : 2019/8/23 21:50
|
||||
# @Author : zhoujun
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
from pprint import pformat
|
||||
|
||||
import anyconfig
|
||||
import paddle
|
||||
import numpy as np
|
||||
import random
|
||||
from paddle.jit import to_static
|
||||
from paddle.static import InputSpec
|
||||
|
||||
from utils import setup_logger
|
||||
|
||||
|
||||
class BaseTrainer:
|
||||
def __init__(
|
||||
self,
|
||||
config,
|
||||
model,
|
||||
criterion,
|
||||
train_loader,
|
||||
validate_loader,
|
||||
metric_cls,
|
||||
post_process=None,
|
||||
):
|
||||
config["trainer"]["output_dir"] = os.path.join(
|
||||
str(pathlib.Path(os.path.abspath(__name__)).parent),
|
||||
config["trainer"]["output_dir"],
|
||||
)
|
||||
config["name"] = config["name"] + "_" + model.name
|
||||
self.save_dir = config["trainer"]["output_dir"]
|
||||
self.checkpoint_dir = os.path.join(self.save_dir, "checkpoint")
|
||||
|
||||
os.makedirs(self.checkpoint_dir, exist_ok=True)
|
||||
|
||||
self.global_step = 0
|
||||
self.start_epoch = 0
|
||||
self.config = config
|
||||
self.criterion = criterion
|
||||
# logger and tensorboard
|
||||
self.visualdl_enable = self.config["trainer"].get("visual_dl", False)
|
||||
self.epochs = self.config["trainer"]["epochs"]
|
||||
self.log_iter = self.config["trainer"]["log_iter"]
|
||||
if paddle.distributed.get_rank() == 0:
|
||||
anyconfig.dump(config, os.path.join(self.save_dir, "config.yaml"))
|
||||
self.logger = setup_logger(os.path.join(self.save_dir, "train.log"))
|
||||
self.logger_info(pformat(self.config))
|
||||
|
||||
self.model = self.apply_to_static(model)
|
||||
|
||||
# device
|
||||
if (
|
||||
paddle.device.cuda.device_count() > 0
|
||||
and paddle.device.is_compiled_with_cuda()
|
||||
):
|
||||
self.with_cuda = True
|
||||
random.seed(self.config["trainer"]["seed"])
|
||||
np.random.seed(self.config["trainer"]["seed"])
|
||||
paddle.seed(self.config["trainer"]["seed"])
|
||||
else:
|
||||
self.with_cuda = False
|
||||
self.logger_info("train with and paddle {}".format(paddle.__version__))
|
||||
# metrics
|
||||
self.metrics = {
|
||||
"recall": 0,
|
||||
"precision": 0,
|
||||
"hmean": 0,
|
||||
"train_loss": float("inf"),
|
||||
"best_model_epoch": 0,
|
||||
}
|
||||
|
||||
self.train_loader = train_loader
|
||||
if validate_loader is not None:
|
||||
assert post_process is not None and metric_cls is not None
|
||||
self.validate_loader = validate_loader
|
||||
self.post_process = post_process
|
||||
self.metric_cls = metric_cls
|
||||
self.train_loader_len = len(train_loader)
|
||||
|
||||
if self.validate_loader is not None:
|
||||
self.logger_info(
|
||||
"train dataset has {} samples,{} in dataloader, validate dataset has {} samples,{} in dataloader".format(
|
||||
len(self.train_loader.dataset),
|
||||
self.train_loader_len,
|
||||
len(self.validate_loader.dataset),
|
||||
len(self.validate_loader),
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.logger_info(
|
||||
"train dataset has {} samples,{} in dataloader".format(
|
||||
len(self.train_loader.dataset), self.train_loader_len
|
||||
)
|
||||
)
|
||||
|
||||
self._initialize_scheduler()
|
||||
|
||||
self._initialize_optimizer()
|
||||
|
||||
# resume or finetune
|
||||
if self.config["trainer"]["resume_checkpoint"] != "":
|
||||
self._load_checkpoint(
|
||||
self.config["trainer"]["resume_checkpoint"], resume=True
|
||||
)
|
||||
elif self.config["trainer"]["finetune_checkpoint"] != "":
|
||||
self._load_checkpoint(
|
||||
self.config["trainer"]["finetune_checkpoint"], resume=False
|
||||
)
|
||||
|
||||
if self.visualdl_enable and paddle.distributed.get_rank() == 0:
|
||||
from visualdl import LogWriter
|
||||
|
||||
self.writer = LogWriter(self.save_dir)
|
||||
|
||||
# 混合精度训练
|
||||
self.amp = self.config.get("amp", None)
|
||||
if self.amp == "None":
|
||||
self.amp = None
|
||||
if self.amp:
|
||||
self.amp["scaler"] = paddle.amp.GradScaler(
|
||||
init_loss_scaling=self.amp.get("scale_loss", 1024),
|
||||
use_dynamic_loss_scaling=self.amp.get("use_dynamic_loss_scaling", True),
|
||||
)
|
||||
self.model, self.optimizer = paddle.amp.decorate(
|
||||
models=self.model,
|
||||
optimizers=self.optimizer,
|
||||
level=self.amp.get("amp_level", "O2"),
|
||||
)
|
||||
|
||||
# 分布式训练
|
||||
if paddle.device.cuda.device_count() > 1:
|
||||
self.model = paddle.DataParallel(self.model)
|
||||
# make inverse Normalize
|
||||
self.UN_Normalize = False
|
||||
for t in self.config["dataset"]["train"]["dataset"]["args"]["transforms"]:
|
||||
if t["type"] == "Normalize":
|
||||
self.normalize_mean = t["args"]["mean"]
|
||||
self.normalize_std = t["args"]["std"]
|
||||
self.UN_Normalize = True
|
||||
|
||||
def apply_to_static(self, model):
|
||||
support_to_static = self.config["trainer"].get("to_static", False)
|
||||
if support_to_static:
|
||||
specs = None
|
||||
print("static")
|
||||
specs = [InputSpec([None, 3, -1, -1])]
|
||||
model = to_static(model, input_spec=specs)
|
||||
self.logger_info(
|
||||
"Successfully to apply @to_static with specs: {}".format(specs)
|
||||
)
|
||||
return model
|
||||
|
||||
def train(self):
|
||||
"""
|
||||
Full training logic
|
||||
"""
|
||||
for epoch in range(self.start_epoch + 1, self.epochs + 1):
|
||||
self.epoch_result = self._train_epoch(epoch)
|
||||
self._on_epoch_finish()
|
||||
if paddle.distributed.get_rank() == 0 and self.visualdl_enable:
|
||||
self.writer.close()
|
||||
self._on_train_finish()
|
||||
|
||||
def _train_epoch(self, epoch):
|
||||
"""
|
||||
Training logic for an epoch
|
||||
|
||||
:param epoch: Current epoch number
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def _eval(self, epoch):
|
||||
"""
|
||||
eval logic for an epoch
|
||||
|
||||
:param epoch: Current epoch number
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def _on_epoch_finish(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def _on_train_finish(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def _save_checkpoint(self, epoch, file_name):
|
||||
"""
|
||||
Saving checkpoints
|
||||
|
||||
:param epoch: current epoch number
|
||||
:param log: logging information of the epoch
|
||||
:param save_best: if True, rename the saved checkpoint to 'model_best.pth.tar'
|
||||
"""
|
||||
state_dict = self.model.state_dict()
|
||||
state = {
|
||||
"epoch": epoch,
|
||||
"global_step": self.global_step,
|
||||
"state_dict": state_dict,
|
||||
"optimizer": self.optimizer.state_dict(),
|
||||
"config": self.config,
|
||||
"metrics": self.metrics,
|
||||
}
|
||||
filename = os.path.join(self.checkpoint_dir, file_name)
|
||||
paddle.save(state, filename)
|
||||
|
||||
def _load_checkpoint(self, checkpoint_path, resume):
|
||||
"""
|
||||
Resume from saved checkpoints
|
||||
:param checkpoint_path: Checkpoint path to be resumed
|
||||
"""
|
||||
self.logger_info("Loading checkpoint: {} ...".format(checkpoint_path))
|
||||
checkpoint = paddle.load(checkpoint_path)
|
||||
self.model.set_state_dict(checkpoint["state_dict"])
|
||||
if resume:
|
||||
self.global_step = checkpoint["global_step"]
|
||||
self.start_epoch = checkpoint["epoch"]
|
||||
self.config["lr_scheduler"]["args"]["last_epoch"] = self.start_epoch
|
||||
# self.scheduler.load_state_dict(checkpoint['scheduler'])
|
||||
self.optimizer.set_state_dict(checkpoint["optimizer"])
|
||||
if "metrics" in checkpoint:
|
||||
self.metrics = checkpoint["metrics"]
|
||||
self.logger_info(
|
||||
"resume from checkpoint {} (epoch {})".format(
|
||||
checkpoint_path, self.start_epoch
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.logger_info("finetune from checkpoint {}".format(checkpoint_path))
|
||||
|
||||
def _initialize(self, name, module, *args, **kwargs):
|
||||
module_name = self.config[name]["type"]
|
||||
module_args = self.config[name].get("args", {})
|
||||
assert all(
|
||||
[k not in module_args for k in kwargs]
|
||||
), "Overwriting kwargs given in config file is not allowed"
|
||||
module_args.update(kwargs)
|
||||
return getattr(module, module_name)(*args, **module_args)
|
||||
|
||||
def _initialize_scheduler(self):
|
||||
self.lr_scheduler = self._initialize("lr_scheduler", paddle.optimizer.lr)
|
||||
|
||||
def _initialize_optimizer(self):
|
||||
self.optimizer = self._initialize(
|
||||
"optimizer",
|
||||
paddle.optimizer,
|
||||
parameters=self.model.parameters(),
|
||||
learning_rate=self.lr_scheduler,
|
||||
)
|
||||
|
||||
def inverse_normalize(self, batch_img):
|
||||
if self.UN_Normalize:
|
||||
batch_img[:, 0, :, :] = (
|
||||
batch_img[:, 0, :, :] * self.normalize_std[0] + self.normalize_mean[0]
|
||||
)
|
||||
batch_img[:, 1, :, :] = (
|
||||
batch_img[:, 1, :, :] * self.normalize_std[1] + self.normalize_mean[1]
|
||||
)
|
||||
batch_img[:, 2, :, :] = (
|
||||
batch_img[:, 2, :, :] * self.normalize_std[2] + self.normalize_mean[2]
|
||||
)
|
||||
|
||||
def logger_info(self, s):
|
||||
if paddle.distributed.get_rank() == 0:
|
||||
self.logger.info(s)
|
||||
@@ -0,0 +1,40 @@
|
||||
name: DBNet
|
||||
dataset:
|
||||
train:
|
||||
dataset:
|
||||
type: SynthTextDataset # 数据集类型
|
||||
args:
|
||||
data_path: ''# SynthTextDataset 根目录
|
||||
pre_processes: # 数据的预处理过程,包含augment和标签制作
|
||||
- type: IaaAugment # 使用imgaug进行变换
|
||||
args:
|
||||
- {'type':Fliplr, 'args':{'p':0.5}}
|
||||
- {'type': Affine, 'args':{'rotate':[-10,10]}}
|
||||
- {'type':Resize,'args':{'size':[0.5,3]}}
|
||||
- type: EastRandomCropData
|
||||
args:
|
||||
size: [640,640]
|
||||
max_tries: 50
|
||||
keep_ratio: true
|
||||
- type: MakeBorderMap
|
||||
args:
|
||||
shrink_ratio: 0.4
|
||||
- type: MakeShrinkMap
|
||||
args:
|
||||
shrink_ratio: 0.4
|
||||
min_text_size: 8
|
||||
transforms: # 对图片进行的变换方式
|
||||
- type: ToTensor
|
||||
args: {}
|
||||
- type: Normalize
|
||||
args:
|
||||
mean: [0.485, 0.456, 0.406]
|
||||
std: [0.229, 0.224, 0.225]
|
||||
img_mode: RGB
|
||||
filter_keys: ['img_path','img_name','text_polys','texts','ignore_tags','shape'] # 返回数据之前,从数据字典里删除的key
|
||||
ignore_tags: ['*', '###']
|
||||
loader:
|
||||
batch_size: 1
|
||||
shuffle: true
|
||||
num_workers: 0
|
||||
collate_fn: ''
|
||||
@@ -0,0 +1,65 @@
|
||||
name: DBNet
|
||||
base: ['config/SynthText.yaml']
|
||||
arch:
|
||||
type: Model
|
||||
backbone:
|
||||
type: resnet18
|
||||
pretrained: true
|
||||
neck:
|
||||
type: FPN
|
||||
inner_channels: 256
|
||||
head:
|
||||
type: DBHead
|
||||
out_channels: 2
|
||||
k: 50
|
||||
post_processing:
|
||||
type: SegDetectorRepresenter
|
||||
args:
|
||||
thresh: 0.3
|
||||
box_thresh: 0.7
|
||||
max_candidates: 1000
|
||||
unclip_ratio: 1.5 # from paper
|
||||
metric:
|
||||
type: QuadMetric
|
||||
args:
|
||||
is_output_polygon: false
|
||||
loss:
|
||||
type: DBLoss
|
||||
alpha: 1
|
||||
beta: 10
|
||||
ohem_ratio: 3
|
||||
optimizer:
|
||||
type: Adam
|
||||
args:
|
||||
lr: 0.001
|
||||
weight_decay: 0
|
||||
amsgrad: true
|
||||
lr_scheduler:
|
||||
type: WarmupPolyLR
|
||||
args:
|
||||
warmup_epoch: 3
|
||||
trainer:
|
||||
seed: 2
|
||||
epochs: 1200
|
||||
log_iter: 10
|
||||
show_images_iter: 50
|
||||
resume_checkpoint: ''
|
||||
finetune_checkpoint: ''
|
||||
output_dir: output
|
||||
visual_dl: false
|
||||
amp:
|
||||
scale_loss: 1024
|
||||
amp_level: O2
|
||||
custom_white_list: []
|
||||
custom_black_list: ['exp', 'sigmoid', 'concat']
|
||||
dataset:
|
||||
train:
|
||||
dataset:
|
||||
args:
|
||||
data_path: ./datasets/SynthText
|
||||
img_mode: RGB
|
||||
loader:
|
||||
batch_size: 2
|
||||
shuffle: true
|
||||
num_workers: 6
|
||||
collate_fn: ''
|
||||
@@ -0,0 +1,69 @@
|
||||
name: DBNet
|
||||
dataset:
|
||||
train:
|
||||
dataset:
|
||||
type: ICDAR2015Dataset # 数据集类型
|
||||
args:
|
||||
data_path: # 一个存放 img_path \t gt_path的文件
|
||||
- ''
|
||||
pre_processes: # 数据的预处理过程,包含augment和标签制作
|
||||
- type: IaaAugment # 使用imgaug进行变换
|
||||
args:
|
||||
- {'type':Fliplr, 'args':{'p':0.5}}
|
||||
- {'type': Affine, 'args':{'rotate':[-10,10]}}
|
||||
- {'type':Resize,'args':{'size':[0.5,3]}}
|
||||
- type: EastRandomCropData
|
||||
args:
|
||||
size: [640,640]
|
||||
max_tries: 50
|
||||
keep_ratio: true
|
||||
- type: MakeBorderMap
|
||||
args:
|
||||
shrink_ratio: 0.4
|
||||
thresh_min: 0.3
|
||||
thresh_max: 0.7
|
||||
- type: MakeShrinkMap
|
||||
args:
|
||||
shrink_ratio: 0.4
|
||||
min_text_size: 8
|
||||
transforms: # 对图片进行的变换方式
|
||||
- type: ToTensor
|
||||
args: {}
|
||||
- type: Normalize
|
||||
args:
|
||||
mean: [0.485, 0.456, 0.406]
|
||||
std: [0.229, 0.224, 0.225]
|
||||
img_mode: RGB
|
||||
filter_keys: [img_path,img_name,text_polys,texts,ignore_tags,shape] # 返回数据之前,从数据字典里删除的key
|
||||
ignore_tags: ['*', '###']
|
||||
loader:
|
||||
batch_size: 1
|
||||
shuffle: true
|
||||
num_workers: 0
|
||||
collate_fn: ''
|
||||
validate:
|
||||
dataset:
|
||||
type: ICDAR2015Dataset
|
||||
args:
|
||||
data_path:
|
||||
- ''
|
||||
pre_processes:
|
||||
- type: ResizeShortSize
|
||||
args:
|
||||
short_size: 736
|
||||
resize_text_polys: false
|
||||
transforms:
|
||||
- type: ToTensor
|
||||
args: {}
|
||||
- type: Normalize
|
||||
args:
|
||||
mean: [0.485, 0.456, 0.406]
|
||||
std: [0.229, 0.224, 0.225]
|
||||
img_mode: RGB
|
||||
filter_keys: []
|
||||
ignore_tags: ['*', '###']
|
||||
loader:
|
||||
batch_size: 1
|
||||
shuffle: true
|
||||
num_workers: 0
|
||||
collate_fn: ICDARCollectFN
|
||||
@@ -0,0 +1,82 @@
|
||||
name: DBNet
|
||||
base: ['config/icdar2015.yaml']
|
||||
arch:
|
||||
type: Model
|
||||
backbone:
|
||||
type: deformable_resnet18
|
||||
pretrained: true
|
||||
neck:
|
||||
type: FPN
|
||||
inner_channels: 256
|
||||
head:
|
||||
type: DBHead
|
||||
out_channels: 2
|
||||
k: 50
|
||||
post_processing:
|
||||
type: SegDetectorRepresenter
|
||||
args:
|
||||
thresh: 0.3
|
||||
box_thresh: 0.7
|
||||
max_candidates: 1000
|
||||
unclip_ratio: 1.5 # from paper
|
||||
metric:
|
||||
type: QuadMetric
|
||||
args:
|
||||
is_output_polygon: false
|
||||
loss:
|
||||
type: DBLoss
|
||||
alpha: 1
|
||||
beta: 10
|
||||
ohem_ratio: 3
|
||||
optimizer:
|
||||
type: Adam
|
||||
args:
|
||||
lr: 0.001
|
||||
weight_decay: 0
|
||||
amsgrad: true
|
||||
lr_scheduler:
|
||||
type: WarmupPolyLR
|
||||
args:
|
||||
warmup_epoch: 3
|
||||
trainer:
|
||||
seed: 2
|
||||
epochs: 1200
|
||||
log_iter: 10
|
||||
show_images_iter: 50
|
||||
resume_checkpoint: ''
|
||||
finetune_checkpoint: ''
|
||||
output_dir: output
|
||||
visual_dl: false
|
||||
amp:
|
||||
scale_loss: 1024
|
||||
amp_level: O2
|
||||
custom_white_list: []
|
||||
custom_black_list: ['exp', 'sigmoid', 'concat']
|
||||
dataset:
|
||||
train:
|
||||
dataset:
|
||||
args:
|
||||
data_path:
|
||||
- ./datasets/train.txt
|
||||
img_mode: RGB
|
||||
loader:
|
||||
batch_size: 1
|
||||
shuffle: true
|
||||
num_workers: 6
|
||||
collate_fn: ''
|
||||
validate:
|
||||
dataset:
|
||||
args:
|
||||
data_path:
|
||||
- ./datasets/test.txt
|
||||
pre_processes:
|
||||
- type: ResizeShortSize
|
||||
args:
|
||||
short_size: 736
|
||||
resize_text_polys: false
|
||||
img_mode: RGB
|
||||
loader:
|
||||
batch_size: 1
|
||||
shuffle: true
|
||||
num_workers: 6
|
||||
collate_fn: ICDARCollectFN
|
||||
@@ -0,0 +1,82 @@
|
||||
name: DBNet
|
||||
base: ['config/icdar2015.yaml']
|
||||
arch:
|
||||
type: Model
|
||||
backbone:
|
||||
type: resnet18
|
||||
pretrained: true
|
||||
neck:
|
||||
type: FPN
|
||||
inner_channels: 256
|
||||
head:
|
||||
type: DBHead
|
||||
out_channels: 2
|
||||
k: 50
|
||||
post_processing:
|
||||
type: SegDetectorRepresenter
|
||||
args:
|
||||
thresh: 0.3
|
||||
box_thresh: 0.7
|
||||
max_candidates: 1000
|
||||
unclip_ratio: 1.5 # from paper
|
||||
metric:
|
||||
type: QuadMetric
|
||||
args:
|
||||
is_output_polygon: false
|
||||
loss:
|
||||
type: DBLoss
|
||||
alpha: 1
|
||||
beta: 10
|
||||
ohem_ratio: 3
|
||||
optimizer:
|
||||
type: Adam
|
||||
args:
|
||||
lr: 0.001
|
||||
weight_decay: 0
|
||||
amsgrad: true
|
||||
lr_scheduler:
|
||||
type: WarmupPolyLR
|
||||
args:
|
||||
warmup_epoch: 3
|
||||
trainer:
|
||||
seed: 2
|
||||
epochs: 1200
|
||||
log_iter: 10
|
||||
show_images_iter: 50
|
||||
resume_checkpoint: ''
|
||||
finetune_checkpoint: ''
|
||||
output_dir: output
|
||||
visual_dl: false
|
||||
amp:
|
||||
scale_loss: 1024
|
||||
amp_level: O2
|
||||
custom_white_list: []
|
||||
custom_black_list: ['exp', 'sigmoid', 'concat']
|
||||
dataset:
|
||||
train:
|
||||
dataset:
|
||||
args:
|
||||
data_path:
|
||||
- ./datasets/train.txt
|
||||
img_mode: RGB
|
||||
loader:
|
||||
batch_size: 1
|
||||
shuffle: true
|
||||
num_workers: 6
|
||||
collate_fn: ''
|
||||
validate:
|
||||
dataset:
|
||||
args:
|
||||
data_path:
|
||||
- ./datasets/test.txt
|
||||
pre_processes:
|
||||
- type: ResizeShortSize
|
||||
args:
|
||||
short_size: 736
|
||||
resize_text_polys: false
|
||||
img_mode: RGB
|
||||
loader:
|
||||
batch_size: 1
|
||||
shuffle: true
|
||||
num_workers: 6
|
||||
collate_fn: ICDARCollectFN
|
||||
@@ -0,0 +1,83 @@
|
||||
name: DBNet
|
||||
base: ['config/icdar2015.yaml']
|
||||
arch:
|
||||
type: Model
|
||||
backbone:
|
||||
type: resnet18
|
||||
pretrained: true
|
||||
neck:
|
||||
type: FPN
|
||||
inner_channels: 256
|
||||
head:
|
||||
type: DBHead
|
||||
out_channels: 2
|
||||
k: 50
|
||||
post_processing:
|
||||
type: SegDetectorRepresenter
|
||||
args:
|
||||
thresh: 0.3
|
||||
box_thresh: 0.7
|
||||
max_candidates: 1000
|
||||
unclip_ratio: 1.5 # from paper
|
||||
metric:
|
||||
type: QuadMetric
|
||||
args:
|
||||
is_output_polygon: false
|
||||
loss:
|
||||
type: DBLoss
|
||||
alpha: 1
|
||||
beta: 10
|
||||
ohem_ratio: 3
|
||||
optimizer:
|
||||
type: Adam
|
||||
args:
|
||||
lr: 0.001
|
||||
weight_decay: 0
|
||||
amsgrad: true
|
||||
lr_scheduler:
|
||||
type: StepLR
|
||||
args:
|
||||
step_size: 10
|
||||
gama: 0.8
|
||||
trainer:
|
||||
seed: 2
|
||||
epochs: 500
|
||||
log_iter: 10
|
||||
show_images_iter: 50
|
||||
resume_checkpoint: ''
|
||||
finetune_checkpoint: ''
|
||||
output_dir: output
|
||||
visual_dl: false
|
||||
amp:
|
||||
scale_loss: 1024
|
||||
amp_level: O2
|
||||
custom_white_list: []
|
||||
custom_black_list: ['exp', 'sigmoid', 'concat']
|
||||
dataset:
|
||||
train:
|
||||
dataset:
|
||||
args:
|
||||
data_path:
|
||||
- ./datasets/train.txt
|
||||
img_mode: RGB
|
||||
loader:
|
||||
batch_size: 1
|
||||
shuffle: true
|
||||
num_workers: 6
|
||||
collate_fn: ''
|
||||
validate:
|
||||
dataset:
|
||||
args:
|
||||
data_path:
|
||||
- ./datasets/test.txt
|
||||
pre_processes:
|
||||
- type: ResizeShortSize
|
||||
args:
|
||||
short_size: 736
|
||||
resize_text_polys: false
|
||||
img_mode: RGB
|
||||
loader:
|
||||
batch_size: 1
|
||||
shuffle: true
|
||||
num_workers: 6
|
||||
collate_fn: ICDARCollectFN
|
||||
@@ -0,0 +1,79 @@
|
||||
name: DBNet
|
||||
base: ['config/icdar2015.yaml']
|
||||
arch:
|
||||
type: Model
|
||||
backbone:
|
||||
type: resnet50
|
||||
pretrained: true
|
||||
neck:
|
||||
type: FPN
|
||||
inner_channels: 256
|
||||
head:
|
||||
type: DBHead
|
||||
out_channels: 2
|
||||
k: 50
|
||||
post_processing:
|
||||
type: SegDetectorRepresenter
|
||||
args:
|
||||
thresh: 0.3
|
||||
box_thresh: 0.7
|
||||
max_candidates: 1000
|
||||
unclip_ratio: 1.5 # from paper
|
||||
metric:
|
||||
type: QuadMetric
|
||||
args:
|
||||
is_output_polygon: false
|
||||
loss:
|
||||
type: DBLoss
|
||||
alpha: 1
|
||||
beta: 10
|
||||
ohem_ratio: 3
|
||||
optimizer:
|
||||
type: Adam
|
||||
lr_scheduler:
|
||||
type: Polynomial
|
||||
args:
|
||||
learning_rate: 0.001
|
||||
warmup_epoch: 3
|
||||
trainer:
|
||||
seed: 2
|
||||
epochs: 1200
|
||||
log_iter: 10
|
||||
show_images_iter: 50
|
||||
resume_checkpoint: ''
|
||||
finetune_checkpoint: ''
|
||||
output_dir: output/fp16_o2
|
||||
visual_dl: false
|
||||
amp:
|
||||
scale_loss: 1024
|
||||
amp_level: O2
|
||||
custom_white_list: []
|
||||
custom_black_list: ['exp', 'sigmoid', 'concat']
|
||||
dataset:
|
||||
train:
|
||||
dataset:
|
||||
args:
|
||||
data_path:
|
||||
- ./datasets/train.txt
|
||||
img_mode: RGB
|
||||
loader:
|
||||
batch_size: 16
|
||||
shuffle: true
|
||||
num_workers: 6
|
||||
collate_fn: ''
|
||||
validate:
|
||||
dataset:
|
||||
args:
|
||||
data_path:
|
||||
- ./datasets/test.txt
|
||||
pre_processes:
|
||||
- type: ResizeShortSize
|
||||
args:
|
||||
short_size: 736
|
||||
resize_text_polys: false
|
||||
img_mode: RGB
|
||||
loader:
|
||||
batch_size: 1
|
||||
shuffle: true
|
||||
num_workers: 6
|
||||
collate_fn: ICDARCollectFN
|
||||
@@ -0,0 +1,73 @@
|
||||
name: DBNet
|
||||
dataset:
|
||||
train:
|
||||
dataset:
|
||||
type: DetDataset # 数据集类型
|
||||
args:
|
||||
data_path: # 一个存放 img_path \t gt_path的文件
|
||||
- ''
|
||||
pre_processes: # 数据的预处理过程,包含augment和标签制作
|
||||
- type: IaaAugment # 使用imgaug进行变换
|
||||
args:
|
||||
- {'type':Fliplr, 'args':{'p':0.5}}
|
||||
- {'type': Affine, 'args':{'rotate':[-10,10]}}
|
||||
- {'type':Resize,'args':{'size':[0.5,3]}}
|
||||
- type: EastRandomCropData
|
||||
args:
|
||||
size: [640,640]
|
||||
max_tries: 50
|
||||
keep_ratio: true
|
||||
- type: MakeBorderMap
|
||||
args:
|
||||
shrink_ratio: 0.4
|
||||
thresh_min: 0.3
|
||||
thresh_max: 0.7
|
||||
- type: MakeShrinkMap
|
||||
args:
|
||||
shrink_ratio: 0.4
|
||||
min_text_size: 8
|
||||
transforms: # 对图片进行的变换方式
|
||||
- type: ToTensor
|
||||
args: {}
|
||||
- type: Normalize
|
||||
args:
|
||||
mean: [0.485, 0.456, 0.406]
|
||||
std: [0.229, 0.224, 0.225]
|
||||
img_mode: RGB
|
||||
load_char_annotation: false
|
||||
expand_one_char: false
|
||||
filter_keys: [img_path,img_name,text_polys,texts,ignore_tags,shape] # 返回数据之前,从数据字典里删除的key
|
||||
ignore_tags: ['*', '###']
|
||||
loader:
|
||||
batch_size: 1
|
||||
shuffle: true
|
||||
num_workers: 0
|
||||
collate_fn: ''
|
||||
validate:
|
||||
dataset:
|
||||
type: DetDataset
|
||||
args:
|
||||
data_path:
|
||||
- ''
|
||||
pre_processes:
|
||||
- type: ResizeShortSize
|
||||
args:
|
||||
short_size: 736
|
||||
resize_text_polys: false
|
||||
transforms:
|
||||
- type: ToTensor
|
||||
args: {}
|
||||
- type: Normalize
|
||||
args:
|
||||
mean: [0.485, 0.456, 0.406]
|
||||
std: [0.229, 0.224, 0.225]
|
||||
img_mode: RGB
|
||||
load_char_annotation: false # 是否加载字符级标注
|
||||
expand_one_char: false # 是否对只有一个字符的框进行宽度扩充,扩充后w = w+h
|
||||
filter_keys: []
|
||||
ignore_tags: ['*', '###']
|
||||
loader:
|
||||
batch_size: 1
|
||||
shuffle: true
|
||||
num_workers: 0
|
||||
collate_fn: ICDARCollectFN
|
||||
@@ -0,0 +1,86 @@
|
||||
name: DBNet
|
||||
base: ['config/open_dataset.yaml']
|
||||
arch:
|
||||
type: Model
|
||||
backbone:
|
||||
type: deformable_resnet18
|
||||
pretrained: true
|
||||
neck:
|
||||
type: FPN
|
||||
inner_channels: 256
|
||||
head:
|
||||
type: DBHead
|
||||
out_channels: 2
|
||||
k: 50
|
||||
post_processing:
|
||||
type: SegDetectorRepresenter
|
||||
args:
|
||||
thresh: 0.3
|
||||
box_thresh: 0.7
|
||||
max_candidates: 1000
|
||||
unclip_ratio: 1.5 # from paper
|
||||
metric:
|
||||
type: QuadMetric
|
||||
args:
|
||||
is_output_polygon: false
|
||||
loss:
|
||||
type: DBLoss
|
||||
alpha: 1
|
||||
beta: 10
|
||||
ohem_ratio: 3
|
||||
optimizer:
|
||||
type: Adam
|
||||
args:
|
||||
lr: 0.001
|
||||
weight_decay: 0
|
||||
amsgrad: true
|
||||
lr_scheduler:
|
||||
type: WarmupPolyLR
|
||||
args:
|
||||
warmup_epoch: 3
|
||||
trainer:
|
||||
seed: 2
|
||||
epochs: 1200
|
||||
log_iter: 1
|
||||
show_images_iter: 1
|
||||
resume_checkpoint: ''
|
||||
finetune_checkpoint: ''
|
||||
output_dir: output
|
||||
visual_dl: false
|
||||
amp:
|
||||
scale_loss: 1024
|
||||
amp_level: O2
|
||||
custom_white_list: []
|
||||
custom_black_list: ['exp', 'sigmoid', 'concat']
|
||||
dataset:
|
||||
train:
|
||||
dataset:
|
||||
args:
|
||||
data_path:
|
||||
- ./datasets/train.json
|
||||
img_mode: RGB
|
||||
load_char_annotation: false
|
||||
expand_one_char: false
|
||||
loader:
|
||||
batch_size: 2
|
||||
shuffle: true
|
||||
num_workers: 6
|
||||
collate_fn: ''
|
||||
validate:
|
||||
dataset:
|
||||
args:
|
||||
data_path:
|
||||
- ./datasets/test.json
|
||||
pre_processes:
|
||||
- type: ResizeShortSize
|
||||
args:
|
||||
short_size: 736
|
||||
resize_text_polys: false
|
||||
img_mode: RGB
|
||||
load_char_annotation: false
|
||||
expand_one_char: false
|
||||
loader:
|
||||
batch_size: 1
|
||||
shuffle: true
|
||||
num_workers: 6
|
||||
collate_fn: ICDARCollectFN
|
||||
@@ -0,0 +1,86 @@
|
||||
name: DBNet
|
||||
base: ['config/open_dataset.yaml']
|
||||
arch:
|
||||
type: Model
|
||||
backbone:
|
||||
type: resnest50
|
||||
pretrained: true
|
||||
neck:
|
||||
type: FPN
|
||||
inner_channels: 256
|
||||
head:
|
||||
type: DBHead
|
||||
out_channels: 2
|
||||
k: 50
|
||||
post_processing:
|
||||
type: SegDetectorRepresenter
|
||||
args:
|
||||
thresh: 0.3
|
||||
box_thresh: 0.7
|
||||
max_candidates: 1000
|
||||
unclip_ratio: 1.5 # from paper
|
||||
metric:
|
||||
type: QuadMetric
|
||||
args:
|
||||
is_output_polygon: false
|
||||
loss:
|
||||
type: DBLoss
|
||||
alpha: 1
|
||||
beta: 10
|
||||
ohem_ratio: 3
|
||||
optimizer:
|
||||
type: Adam
|
||||
args:
|
||||
lr: 0.001
|
||||
weight_decay: 0
|
||||
amsgrad: true
|
||||
lr_scheduler:
|
||||
type: WarmupPolyLR
|
||||
args:
|
||||
warmup_epoch: 3
|
||||
trainer:
|
||||
seed: 2
|
||||
epochs: 1200
|
||||
log_iter: 1
|
||||
show_images_iter: 1
|
||||
resume_checkpoint: ''
|
||||
finetune_checkpoint: ''
|
||||
output_dir: output
|
||||
visual_dl: false
|
||||
amp:
|
||||
scale_loss: 1024
|
||||
amp_level: O2
|
||||
custom_white_list: []
|
||||
custom_black_list: ['exp', 'sigmoid', 'concat']
|
||||
dataset:
|
||||
train:
|
||||
dataset:
|
||||
args:
|
||||
data_path:
|
||||
- ./datasets/train.json
|
||||
img_mode: RGB
|
||||
load_char_annotation: false
|
||||
expand_one_char: false
|
||||
loader:
|
||||
batch_size: 2
|
||||
shuffle: true
|
||||
num_workers: 6
|
||||
collate_fn: ''
|
||||
validate:
|
||||
dataset:
|
||||
args:
|
||||
data_path:
|
||||
- ./datasets/test.json
|
||||
pre_processes:
|
||||
- type: ResizeShortSize
|
||||
args:
|
||||
short_size: 736
|
||||
resize_text_polys: false
|
||||
img_mode: RGB
|
||||
load_char_annotation: false
|
||||
expand_one_char: false
|
||||
loader:
|
||||
batch_size: 1
|
||||
shuffle: true
|
||||
num_workers: 6
|
||||
collate_fn: ICDARCollectFN
|
||||
@@ -0,0 +1,93 @@
|
||||
name: DBNet
|
||||
base: ['config/open_dataset.yaml']
|
||||
arch:
|
||||
type: Model
|
||||
backbone:
|
||||
type: resnet18
|
||||
pretrained: true
|
||||
neck:
|
||||
type: FPN
|
||||
inner_channels: 256
|
||||
head:
|
||||
type: DBHead
|
||||
out_channels: 2
|
||||
k: 50
|
||||
post_processing:
|
||||
type: SegDetectorRepresenter
|
||||
args:
|
||||
thresh: 0.3
|
||||
box_thresh: 0.7
|
||||
max_candidates: 1000
|
||||
unclip_ratio: 1.5 # from paper
|
||||
metric:
|
||||
type: QuadMetric
|
||||
args:
|
||||
is_output_polygon: false
|
||||
loss:
|
||||
type: DBLoss
|
||||
alpha: 1
|
||||
beta: 10
|
||||
ohem_ratio: 3
|
||||
optimizer:
|
||||
type: Adam
|
||||
args:
|
||||
lr: 0.001
|
||||
weight_decay: 0
|
||||
amsgrad: true
|
||||
lr_scheduler:
|
||||
type: WarmupPolyLR
|
||||
args:
|
||||
warmup_epoch: 3
|
||||
trainer:
|
||||
seed: 2
|
||||
epochs: 1200
|
||||
log_iter: 1
|
||||
show_images_iter: 1
|
||||
resume_checkpoint: ''
|
||||
finetune_checkpoint: ''
|
||||
output_dir: output
|
||||
visual_dl: false
|
||||
amp:
|
||||
scale_loss: 1024
|
||||
amp_level: O2
|
||||
custom_white_list: []
|
||||
custom_black_list: ['exp', 'sigmoid', 'concat']
|
||||
dataset:
|
||||
train:
|
||||
dataset:
|
||||
args:
|
||||
data_path:
|
||||
- ./datasets/train.json
|
||||
transforms: # 对图片进行的变换方式
|
||||
- type: ToTensor
|
||||
args: {}
|
||||
- type: Normalize
|
||||
args:
|
||||
mean: [0.485, 0.456, 0.406]
|
||||
std: [0.229, 0.224, 0.225]
|
||||
img_mode: RGB
|
||||
load_char_annotation: false
|
||||
expand_one_char: false
|
||||
loader:
|
||||
batch_size: 2
|
||||
shuffle: true
|
||||
num_workers: 6
|
||||
collate_fn: ''
|
||||
validate:
|
||||
dataset:
|
||||
args:
|
||||
data_path:
|
||||
- ./datasets/test.json
|
||||
pre_processes:
|
||||
- type: ResizeShortSize
|
||||
args:
|
||||
short_size: 736
|
||||
resize_text_polys: false
|
||||
img_mode: RGB
|
||||
load_char_annotation: false
|
||||
expand_one_char: false
|
||||
loader:
|
||||
batch_size: 1
|
||||
shuffle: true
|
||||
num_workers: 6
|
||||
collate_fn: ICDARCollectFN
|
||||
@@ -0,0 +1,114 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# @Time : 2019/8/23 21:52
|
||||
# @Author : zhoujun
|
||||
import copy
|
||||
|
||||
import PIL
|
||||
import numpy as np
|
||||
import paddle
|
||||
from paddle.io import DataLoader, DistributedBatchSampler, BatchSampler
|
||||
|
||||
from paddle.vision import transforms
|
||||
|
||||
|
||||
def get_dataset(data_path, module_name, transform, dataset_args):
|
||||
"""
|
||||
获取训练dataset
|
||||
:param data_path: dataset文件列表,每个文件内以如下格式存储 ‘path/to/img\tlabel’
|
||||
:param module_name: 所使用的自定义dataset名称,目前只支持data_loaders.ImageDataset
|
||||
:param transform: 该数据集使用的transforms
|
||||
:param dataset_args: module_name的参数
|
||||
:return: 如果data_path列表不为空,返回对于的ConcatDataset对象,否则None
|
||||
"""
|
||||
from . import dataset
|
||||
|
||||
s_dataset = getattr(dataset, module_name)(
|
||||
transform=transform, data_path=data_path, **dataset_args
|
||||
)
|
||||
return s_dataset
|
||||
|
||||
|
||||
def get_transforms(transforms_config):
|
||||
tr_list = []
|
||||
for item in transforms_config:
|
||||
if "args" not in item:
|
||||
args = {}
|
||||
else:
|
||||
args = item["args"]
|
||||
cls = getattr(transforms, item["type"])(**args)
|
||||
tr_list.append(cls)
|
||||
tr_list = transforms.Compose(tr_list)
|
||||
return tr_list
|
||||
|
||||
|
||||
class ICDARCollectFN:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def __call__(self, batch):
|
||||
data_dict = {}
|
||||
to_tensor_keys = []
|
||||
for sample in batch:
|
||||
for k, v in sample.items():
|
||||
if k not in data_dict:
|
||||
data_dict[k] = []
|
||||
if isinstance(v, (np.ndarray, paddle.Tensor, PIL.Image.Image)):
|
||||
if k not in to_tensor_keys:
|
||||
to_tensor_keys.append(k)
|
||||
data_dict[k].append(v)
|
||||
for k in to_tensor_keys:
|
||||
data_dict[k] = paddle.stack(data_dict[k], 0)
|
||||
return data_dict
|
||||
|
||||
|
||||
def get_dataloader(module_config, distributed=False):
|
||||
if module_config is None:
|
||||
return None
|
||||
config = copy.deepcopy(module_config)
|
||||
dataset_args = config["dataset"]["args"]
|
||||
if "transforms" in dataset_args:
|
||||
img_transforms = get_transforms(dataset_args.pop("transforms"))
|
||||
else:
|
||||
img_transforms = None
|
||||
# 创建数据集
|
||||
dataset_name = config["dataset"]["type"]
|
||||
data_path = dataset_args.pop("data_path")
|
||||
if data_path == None:
|
||||
return None
|
||||
|
||||
data_path = [x for x in data_path if x is not None]
|
||||
if len(data_path) == 0:
|
||||
return None
|
||||
if (
|
||||
"collate_fn" not in config["loader"]
|
||||
or config["loader"]["collate_fn"] is None
|
||||
or len(config["loader"]["collate_fn"]) == 0
|
||||
):
|
||||
config["loader"]["collate_fn"] = None
|
||||
else:
|
||||
config["loader"]["collate_fn"] = eval(config["loader"]["collate_fn"])()
|
||||
|
||||
_dataset = get_dataset(
|
||||
data_path=data_path,
|
||||
module_name=dataset_name,
|
||||
transform=img_transforms,
|
||||
dataset_args=dataset_args,
|
||||
)
|
||||
sampler = None
|
||||
if distributed:
|
||||
# 3)使用DistributedSampler
|
||||
batch_sampler = DistributedBatchSampler(
|
||||
dataset=_dataset,
|
||||
batch_size=config["loader"].pop("batch_size"),
|
||||
shuffle=config["loader"].pop("shuffle"),
|
||||
)
|
||||
else:
|
||||
batch_sampler = BatchSampler(
|
||||
dataset=_dataset,
|
||||
batch_size=config["loader"].pop("batch_size"),
|
||||
shuffle=config["loader"].pop("shuffle"),
|
||||
)
|
||||
loader = DataLoader(
|
||||
dataset=_dataset, batch_sampler=batch_sampler, **config["loader"]
|
||||
)
|
||||
return loader
|
||||
@@ -0,0 +1,190 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# @Time : 2019/8/23 21:54
|
||||
# @Author : zhoujun
|
||||
import pathlib
|
||||
import os
|
||||
import cv2
|
||||
import numpy as np
|
||||
import scipy.io as sio
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
from base import BaseDataSet
|
||||
from utils import order_points_clockwise, get_datalist, load, expand_polygon
|
||||
|
||||
|
||||
class ICDAR2015Dataset(BaseDataSet):
|
||||
def __init__(
|
||||
self,
|
||||
data_path: str,
|
||||
img_mode,
|
||||
pre_processes,
|
||||
filter_keys,
|
||||
ignore_tags,
|
||||
transform=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
data_path, img_mode, pre_processes, filter_keys, ignore_tags, transform
|
||||
)
|
||||
|
||||
def load_data(self, data_path: str) -> list:
|
||||
data_list = get_datalist(data_path)
|
||||
t_data_list = []
|
||||
for img_path, label_path in data_list:
|
||||
data = self._get_annotation(label_path)
|
||||
if len(data["text_polys"]) > 0:
|
||||
item = {"img_path": img_path, "img_name": pathlib.Path(img_path).stem}
|
||||
item.update(data)
|
||||
t_data_list.append(item)
|
||||
else:
|
||||
print("there is no suit bbox in {}".format(label_path))
|
||||
return t_data_list
|
||||
|
||||
def _get_annotation(self, label_path: str) -> dict:
|
||||
boxes = []
|
||||
texts = []
|
||||
ignores = []
|
||||
with open(label_path, encoding="utf-8", mode="r") as f:
|
||||
for line in f.readlines():
|
||||
params = line.strip().strip("\ufeff").strip("\xef\xbb\xbf").split(",")
|
||||
try:
|
||||
box = order_points_clockwise(
|
||||
np.array(list(map(float, params[:8]))).reshape(-1, 2)
|
||||
)
|
||||
if cv2.contourArea(box) > 0:
|
||||
boxes.append(box)
|
||||
label = params[8]
|
||||
texts.append(label)
|
||||
ignores.append(label in self.ignore_tags)
|
||||
except:
|
||||
print("load label failed on {}".format(label_path))
|
||||
data = {
|
||||
"text_polys": np.array(boxes),
|
||||
"texts": texts,
|
||||
"ignore_tags": ignores,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
class DetDataset(BaseDataSet):
|
||||
def __init__(
|
||||
self,
|
||||
data_path: str,
|
||||
img_mode,
|
||||
pre_processes,
|
||||
filter_keys,
|
||||
ignore_tags,
|
||||
transform=None,
|
||||
**kwargs,
|
||||
):
|
||||
self.load_char_annotation = kwargs["load_char_annotation"]
|
||||
self.expand_one_char = kwargs["expand_one_char"]
|
||||
super().__init__(
|
||||
data_path, img_mode, pre_processes, filter_keys, ignore_tags, transform
|
||||
)
|
||||
|
||||
def load_data(self, data_path: str) -> list:
|
||||
"""
|
||||
从json文件中读取出 文本行的坐标和gt,字符的坐标和gt
|
||||
:param data_path:
|
||||
:return:
|
||||
"""
|
||||
data_list = []
|
||||
for path in data_path:
|
||||
content = load(path)
|
||||
for gt in tqdm(content["data_list"], desc="read file {}".format(path)):
|
||||
img_path = os.path.join(content["data_root"], gt["img_name"])
|
||||
polygons = []
|
||||
texts = []
|
||||
illegibility_list = []
|
||||
language_list = []
|
||||
for annotation in gt["annotations"]:
|
||||
if len(annotation["polygon"]) == 0 or len(annotation["text"]) == 0:
|
||||
continue
|
||||
if len(annotation["text"]) > 1 and self.expand_one_char:
|
||||
annotation["polygon"] = expand_polygon(annotation["polygon"])
|
||||
polygons.append(annotation["polygon"])
|
||||
texts.append(annotation["text"])
|
||||
illegibility_list.append(annotation["illegibility"])
|
||||
language_list.append(annotation["language"])
|
||||
if self.load_char_annotation:
|
||||
for char_annotation in annotation["chars"]:
|
||||
if (
|
||||
len(char_annotation["polygon"]) == 0
|
||||
or len(char_annotation["char"]) == 0
|
||||
):
|
||||
continue
|
||||
polygons.append(char_annotation["polygon"])
|
||||
texts.append(char_annotation["char"])
|
||||
illegibility_list.append(char_annotation["illegibility"])
|
||||
language_list.append(char_annotation["language"])
|
||||
data_list.append(
|
||||
{
|
||||
"img_path": img_path,
|
||||
"img_name": gt["img_name"],
|
||||
"text_polys": np.array(polygons),
|
||||
"texts": texts,
|
||||
"ignore_tags": illegibility_list,
|
||||
}
|
||||
)
|
||||
return data_list
|
||||
|
||||
|
||||
class SynthTextDataset(BaseDataSet):
|
||||
def __init__(
|
||||
self,
|
||||
data_path: str,
|
||||
img_mode,
|
||||
pre_processes,
|
||||
filter_keys,
|
||||
transform=None,
|
||||
**kwargs,
|
||||
):
|
||||
self.transform = transform
|
||||
self.dataRoot = pathlib.Path(data_path)
|
||||
if not self.dataRoot.exists():
|
||||
raise FileNotFoundError("Dataset folder is not exist.")
|
||||
|
||||
self.targetFilePath = self.dataRoot / "gt.mat"
|
||||
if not self.targetFilePath.exists():
|
||||
raise FileExistsError("Target file is not exist.")
|
||||
targets = {}
|
||||
sio.loadmat(
|
||||
self.targetFilePath,
|
||||
targets,
|
||||
squeeze_me=True,
|
||||
struct_as_record=False,
|
||||
variable_names=["imnames", "wordBB", "txt"],
|
||||
)
|
||||
|
||||
self.imageNames = targets["imnames"]
|
||||
self.wordBBoxes = targets["wordBB"]
|
||||
self.transcripts = targets["txt"]
|
||||
super().__init__(data_path, img_mode, pre_processes, filter_keys, transform)
|
||||
|
||||
def load_data(self, data_path: str) -> list:
|
||||
t_data_list = []
|
||||
for imageName, wordBBoxes, texts in zip(
|
||||
self.imageNames, self.wordBBoxes, self.transcripts
|
||||
):
|
||||
item = {}
|
||||
wordBBoxes = (
|
||||
np.expand_dims(wordBBoxes, axis=2)
|
||||
if (wordBBoxes.ndim == 2)
|
||||
else wordBBoxes
|
||||
)
|
||||
_, _, numOfWords = wordBBoxes.shape
|
||||
text_polys = wordBBoxes.reshape(
|
||||
[8, numOfWords], order="F"
|
||||
).T # num_words * 8
|
||||
text_polys = text_polys.reshape(numOfWords, 4, 2) # num_of_words * 4 * 2
|
||||
transcripts = [word for line in texts for word in line.split()]
|
||||
if numOfWords != len(transcripts):
|
||||
continue
|
||||
item["img_path"] = str(self.dataRoot / imageName)
|
||||
item["img_name"] = (self.dataRoot / imageName).stem
|
||||
item["text_polys"] = text_polys
|
||||
item["texts"] = transcripts
|
||||
item["ignore_tags"] = [x in self.ignore_tags for x in transcripts]
|
||||
t_data_list.append(item)
|
||||
return t_data_list
|
||||
@@ -0,0 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# @Time : 2019/12/4 10:53
|
||||
# @Author : zhoujun
|
||||
from .iaa_augment import IaaAugment
|
||||
from .augment import *
|
||||
from .random_crop_data import EastRandomCropData, PSERandomCrop
|
||||
from .make_border_map import MakeBorderMap
|
||||
from .make_shrink_map import MakeShrinkMap
|
||||
@@ -0,0 +1,308 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# @Time : 2019/8/23 21:52
|
||||
# @Author : zhoujun
|
||||
|
||||
import math
|
||||
import numbers
|
||||
import random
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from skimage.util import random_noise
|
||||
|
||||
|
||||
class RandomNoise:
|
||||
def __init__(self, random_rate):
|
||||
self.random_rate = random_rate
|
||||
|
||||
def __call__(self, data: dict):
|
||||
"""
|
||||
对图片加噪声
|
||||
:param data: {'img':,'text_polys':,'texts':,'ignore_tags':}
|
||||
:return:
|
||||
"""
|
||||
if random.random() > self.random_rate:
|
||||
return data
|
||||
data["img"] = (
|
||||
random_noise(data["img"], mode="gaussian", clip=True) * 255
|
||||
).astype(data["img"].dtype)
|
||||
return data
|
||||
|
||||
|
||||
class RandomScale:
|
||||
def __init__(self, scales, random_rate):
|
||||
"""
|
||||
:param scales: 尺度
|
||||
:param random_rate: 随机系数
|
||||
:return:
|
||||
"""
|
||||
self.random_rate = random_rate
|
||||
self.scales = scales
|
||||
|
||||
def __call__(self, data: dict) -> dict:
|
||||
"""
|
||||
从scales中随机选择一个尺度,对图片和文本框进行缩放
|
||||
:param data: {'img':,'text_polys':,'texts':,'ignore_tags':}
|
||||
:return:
|
||||
"""
|
||||
if random.random() > self.random_rate:
|
||||
return data
|
||||
im = data["img"]
|
||||
text_polys = data["text_polys"]
|
||||
|
||||
tmp_text_polys = text_polys.copy()
|
||||
rd_scale = float(np.random.choice(self.scales))
|
||||
im = cv2.resize(im, dsize=None, fx=rd_scale, fy=rd_scale)
|
||||
tmp_text_polys *= rd_scale
|
||||
|
||||
data["img"] = im
|
||||
data["text_polys"] = tmp_text_polys
|
||||
return data
|
||||
|
||||
|
||||
class RandomRotateImgBox:
|
||||
def __init__(self, degrees, random_rate, same_size=False):
|
||||
"""
|
||||
:param degrees: 角度,可以是一个数值或者list
|
||||
:param random_rate: 随机系数
|
||||
:param same_size: 是否保持和原图一样大
|
||||
:return:
|
||||
"""
|
||||
if isinstance(degrees, numbers.Number):
|
||||
if degrees < 0:
|
||||
raise ValueError("If degrees is a single number, it must be positive.")
|
||||
degrees = (-degrees, degrees)
|
||||
elif (
|
||||
isinstance(degrees, list)
|
||||
or isinstance(degrees, tuple)
|
||||
or isinstance(degrees, np.ndarray)
|
||||
):
|
||||
if len(degrees) != 2:
|
||||
raise ValueError("If degrees is a sequence, it must be of len 2.")
|
||||
degrees = degrees
|
||||
else:
|
||||
raise Exception("degrees must in Number or list or tuple or np.ndarray")
|
||||
self.degrees = degrees
|
||||
self.same_size = same_size
|
||||
self.random_rate = random_rate
|
||||
|
||||
def __call__(self, data: dict) -> dict:
|
||||
"""
|
||||
从scales中随机选择一个尺度,对图片和文本框进行缩放
|
||||
:param data: {'img':,'text_polys':,'texts':,'ignore_tags':}
|
||||
:return:
|
||||
"""
|
||||
if random.random() > self.random_rate:
|
||||
return data
|
||||
im = data["img"]
|
||||
text_polys = data["text_polys"]
|
||||
|
||||
# ---------------------- 旋转图像 ----------------------
|
||||
w = im.shape[1]
|
||||
h = im.shape[0]
|
||||
angle = np.random.uniform(self.degrees[0], self.degrees[1])
|
||||
|
||||
if self.same_size:
|
||||
nw = w
|
||||
nh = h
|
||||
else:
|
||||
# 角度变弧度
|
||||
rangle = np.deg2rad(angle)
|
||||
# 计算旋转之后图像的w, h
|
||||
nw = abs(np.sin(rangle) * h) + abs(np.cos(rangle) * w)
|
||||
nh = abs(np.cos(rangle) * h) + abs(np.sin(rangle) * w)
|
||||
# 构造仿射矩阵
|
||||
rot_mat = cv2.getRotationMatrix2D((nw * 0.5, nh * 0.5), angle, 1)
|
||||
# 计算原图中心点到新图中心点的偏移量
|
||||
rot_move = np.dot(rot_mat, np.array([(nw - w) * 0.5, (nh - h) * 0.5, 0]))
|
||||
# 更新仿射矩阵
|
||||
rot_mat[0, 2] += rot_move[0]
|
||||
rot_mat[1, 2] += rot_move[1]
|
||||
# 仿射变换
|
||||
rot_img = cv2.warpAffine(
|
||||
im,
|
||||
rot_mat,
|
||||
(int(math.ceil(nw)), int(math.ceil(nh))),
|
||||
flags=cv2.INTER_LANCZOS4,
|
||||
)
|
||||
|
||||
# ---------------------- 矫正bbox坐标 ----------------------
|
||||
# rot_mat是最终的旋转矩阵
|
||||
# 获取原始bbox的四个中点,然后将这四个点转换到旋转后的坐标系下
|
||||
rot_text_polys = list()
|
||||
for bbox in text_polys:
|
||||
point1 = np.dot(rot_mat, np.array([bbox[0, 0], bbox[0, 1], 1]))
|
||||
point2 = np.dot(rot_mat, np.array([bbox[1, 0], bbox[1, 1], 1]))
|
||||
point3 = np.dot(rot_mat, np.array([bbox[2, 0], bbox[2, 1], 1]))
|
||||
point4 = np.dot(rot_mat, np.array([bbox[3, 0], bbox[3, 1], 1]))
|
||||
rot_text_polys.append([point1, point2, point3, point4])
|
||||
data["img"] = rot_img
|
||||
data["text_polys"] = np.array(rot_text_polys)
|
||||
return data
|
||||
|
||||
|
||||
class RandomResize:
|
||||
def __init__(self, size, random_rate, keep_ratio=False):
|
||||
"""
|
||||
:param input_size: resize尺寸,数字或者list的形式,如果为list形式,就是[w,h]
|
||||
:param random_rate: 随机系数
|
||||
:param keep_ratio: 是否保持长宽比
|
||||
:return:
|
||||
"""
|
||||
if isinstance(size, numbers.Number):
|
||||
if size < 0:
|
||||
raise ValueError(
|
||||
"If input_size is a single number, it must be positive."
|
||||
)
|
||||
size = (size, size)
|
||||
elif (
|
||||
isinstance(size, list)
|
||||
or isinstance(size, tuple)
|
||||
or isinstance(size, np.ndarray)
|
||||
):
|
||||
if len(size) != 2:
|
||||
raise ValueError("If input_size is a sequence, it must be of len 2.")
|
||||
size = (size[0], size[1])
|
||||
else:
|
||||
raise Exception("input_size must in Number or list or tuple or np.ndarray")
|
||||
self.size = size
|
||||
self.keep_ratio = keep_ratio
|
||||
self.random_rate = random_rate
|
||||
|
||||
def __call__(self, data: dict) -> dict:
|
||||
"""
|
||||
从scales中随机选择一个尺度,对图片和文本框进行缩放
|
||||
:param data: {'img':,'text_polys':,'texts':,'ignore_tags':}
|
||||
:return:
|
||||
"""
|
||||
if random.random() > self.random_rate:
|
||||
return data
|
||||
im = data["img"]
|
||||
text_polys = data["text_polys"]
|
||||
|
||||
if self.keep_ratio:
|
||||
# 将图片短边pad到和长边一样
|
||||
h, w, c = im.shape
|
||||
max_h = max(h, self.size[0])
|
||||
max_w = max(w, self.size[1])
|
||||
im_padded = np.zeros((max_h, max_w, c), dtype=np.uint8)
|
||||
im_padded[:h, :w] = im.copy()
|
||||
im = im_padded
|
||||
text_polys = text_polys.astype(np.float32)
|
||||
h, w, _ = im.shape
|
||||
im = cv2.resize(im, self.size)
|
||||
w_scale = self.size[0] / float(w)
|
||||
h_scale = self.size[1] / float(h)
|
||||
text_polys[:, :, 0] *= w_scale
|
||||
text_polys[:, :, 1] *= h_scale
|
||||
|
||||
data["img"] = im
|
||||
data["text_polys"] = text_polys
|
||||
return data
|
||||
|
||||
|
||||
def resize_image(img, short_size):
|
||||
height, width, _ = img.shape
|
||||
if height < width:
|
||||
new_height = short_size
|
||||
new_width = new_height / height * width
|
||||
else:
|
||||
new_width = short_size
|
||||
new_height = new_width / width * height
|
||||
new_height = int(round(new_height / 32) * 32)
|
||||
new_width = int(round(new_width / 32) * 32)
|
||||
resized_img = cv2.resize(img, (new_width, new_height))
|
||||
return resized_img, (new_width / width, new_height / height)
|
||||
|
||||
|
||||
class ResizeShortSize:
|
||||
def __init__(self, short_size, resize_text_polys=True):
|
||||
"""
|
||||
:param size: resize尺寸,数字或者list的形式,如果为list形式,就是[w,h]
|
||||
:return:
|
||||
"""
|
||||
self.short_size = short_size
|
||||
self.resize_text_polys = resize_text_polys
|
||||
|
||||
def __call__(self, data: dict) -> dict:
|
||||
"""
|
||||
对图片和文本框进行缩放
|
||||
:param data: {'img':,'text_polys':,'texts':,'ignore_tags':}
|
||||
:return:
|
||||
"""
|
||||
im = data["img"]
|
||||
text_polys = data["text_polys"]
|
||||
|
||||
h, w, _ = im.shape
|
||||
short_edge = min(h, w)
|
||||
if short_edge < self.short_size:
|
||||
# 保证短边 >= short_size
|
||||
scale = self.short_size / short_edge
|
||||
im = cv2.resize(im, dsize=None, fx=scale, fy=scale)
|
||||
scale = (scale, scale)
|
||||
# im, scale = resize_image(im, self.short_size)
|
||||
if self.resize_text_polys:
|
||||
# text_polys *= scale
|
||||
text_polys[:, 0] *= scale[0]
|
||||
text_polys[:, 1] *= scale[1]
|
||||
|
||||
data["img"] = im
|
||||
data["text_polys"] = text_polys
|
||||
return data
|
||||
|
||||
|
||||
class HorizontalFlip:
|
||||
def __init__(self, random_rate):
|
||||
"""
|
||||
|
||||
:param random_rate: 随机系数
|
||||
"""
|
||||
self.random_rate = random_rate
|
||||
|
||||
def __call__(self, data: dict) -> dict:
|
||||
"""
|
||||
从scales中随机选择一个尺度,对图片和文本框进行缩放
|
||||
:param data: {'img':,'text_polys':,'texts':,'ignore_tags':}
|
||||
:return:
|
||||
"""
|
||||
if random.random() > self.random_rate:
|
||||
return data
|
||||
im = data["img"]
|
||||
text_polys = data["text_polys"]
|
||||
|
||||
flip_text_polys = text_polys.copy()
|
||||
flip_im = cv2.flip(im, 1)
|
||||
h, w, _ = flip_im.shape
|
||||
flip_text_polys[:, :, 0] = w - flip_text_polys[:, :, 0]
|
||||
|
||||
data["img"] = flip_im
|
||||
data["text_polys"] = flip_text_polys
|
||||
return data
|
||||
|
||||
|
||||
class VerticalFlip:
|
||||
def __init__(self, random_rate):
|
||||
"""
|
||||
|
||||
:param random_rate: 随机系数
|
||||
"""
|
||||
self.random_rate = random_rate
|
||||
|
||||
def __call__(self, data: dict) -> dict:
|
||||
"""
|
||||
从scales中随机选择一个尺度,对图片和文本框进行缩放
|
||||
:param data: {'img':,'text_polys':,'texts':,'ignore_tags':}
|
||||
:return:
|
||||
"""
|
||||
if random.random() > self.random_rate:
|
||||
return data
|
||||
im = data["img"]
|
||||
text_polys = data["text_polys"]
|
||||
|
||||
flip_text_polys = text_polys.copy()
|
||||
flip_im = cv2.flip(im, 0)
|
||||
h, w, _ = flip_im.shape
|
||||
flip_text_polys[:, :, 1] = h - flip_text_polys[:, :, 1]
|
||||
data["img"] = flip_im
|
||||
data["text_polys"] = flip_text_polys
|
||||
return data
|
||||
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# @Time : 2019/12/4 18:06
|
||||
# @Author : zhoujun
|
||||
import numpy as np
|
||||
import imgaug
|
||||
import imgaug.augmenters as iaa
|
||||
|
||||
|
||||
class AugmenterBuilder(object):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def build(self, args, root=True):
|
||||
if args is None or len(args) == 0:
|
||||
return None
|
||||
elif isinstance(args, list):
|
||||
if root:
|
||||
sequence = [self.build(value, root=False) for value in args]
|
||||
return iaa.Sequential(sequence)
|
||||
else:
|
||||
return getattr(iaa, args[0])(
|
||||
*[self.to_tuple_if_list(a) for a in args[1:]]
|
||||
)
|
||||
elif isinstance(args, dict):
|
||||
cls = getattr(iaa, args["type"])
|
||||
return cls(**{k: self.to_tuple_if_list(v) for k, v in args["args"].items()})
|
||||
else:
|
||||
raise RuntimeError("unknown augmenter arg: " + str(args))
|
||||
|
||||
def to_tuple_if_list(self, obj):
|
||||
if isinstance(obj, list):
|
||||
return tuple(obj)
|
||||
return obj
|
||||
|
||||
|
||||
class IaaAugment:
|
||||
def __init__(self, augmenter_args):
|
||||
self.augmenter_args = augmenter_args
|
||||
self.augmenter = AugmenterBuilder().build(self.augmenter_args)
|
||||
|
||||
def __call__(self, data):
|
||||
image = data["img"]
|
||||
shape = image.shape
|
||||
|
||||
if self.augmenter:
|
||||
aug = self.augmenter.to_deterministic()
|
||||
data["img"] = aug.augment_image(image)
|
||||
data = self.may_augment_annotation(aug, data, shape)
|
||||
return data
|
||||
|
||||
def may_augment_annotation(self, aug, data, shape):
|
||||
if aug is None:
|
||||
return data
|
||||
|
||||
line_polys = []
|
||||
for poly in data["text_polys"]:
|
||||
new_poly = self.may_augment_poly(aug, shape, poly)
|
||||
line_polys.append(new_poly)
|
||||
data["text_polys"] = np.array(line_polys)
|
||||
return data
|
||||
|
||||
def may_augment_poly(self, aug, img_shape, poly):
|
||||
keypoints = [imgaug.Keypoint(p[0], p[1]) for p in poly]
|
||||
keypoints = aug.augment_keypoints(
|
||||
[imgaug.KeypointsOnImage(keypoints, shape=img_shape)]
|
||||
)[0].keypoints
|
||||
poly = [(p.x, p.y) for p in keypoints]
|
||||
return poly
|
||||
@@ -0,0 +1,159 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
np.seterr(divide="ignore", invalid="ignore")
|
||||
import pyclipper
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
|
||||
class MakeBorderMap:
|
||||
def __init__(self, shrink_ratio=0.4, thresh_min=0.3, thresh_max=0.7):
|
||||
self.shrink_ratio = shrink_ratio
|
||||
self.thresh_min = thresh_min
|
||||
self.thresh_max = thresh_max
|
||||
|
||||
def __call__(self, data: dict) -> dict:
|
||||
"""
|
||||
从scales中随机选择一个尺度,对图片和文本框进行缩放
|
||||
:param data: {'img':,'text_polys':,'texts':,'ignore_tags':}
|
||||
:return:
|
||||
"""
|
||||
im = data["img"]
|
||||
text_polys = data["text_polys"]
|
||||
ignore_tags = data["ignore_tags"]
|
||||
|
||||
canvas = np.zeros(im.shape[:2], dtype=np.float32)
|
||||
mask = np.zeros(im.shape[:2], dtype=np.float32)
|
||||
|
||||
for i in range(len(text_polys)):
|
||||
if ignore_tags[i]:
|
||||
continue
|
||||
self.draw_border_map(text_polys[i], canvas, mask=mask)
|
||||
canvas = canvas * (self.thresh_max - self.thresh_min) + self.thresh_min
|
||||
|
||||
data["threshold_map"] = canvas
|
||||
data["threshold_mask"] = mask
|
||||
return data
|
||||
|
||||
def draw_border_map(self, polygon, canvas, mask):
|
||||
polygon = np.array(polygon)
|
||||
assert polygon.ndim == 2
|
||||
assert polygon.shape[1] == 2
|
||||
|
||||
polygon_shape = Polygon(polygon)
|
||||
if polygon_shape.area <= 0:
|
||||
return
|
||||
distance = (
|
||||
polygon_shape.area
|
||||
* (1 - np.power(self.shrink_ratio, 2))
|
||||
/ polygon_shape.length
|
||||
)
|
||||
subject = [tuple(l) for l in polygon]
|
||||
padding = pyclipper.PyclipperOffset()
|
||||
padding.AddPath(subject, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON)
|
||||
|
||||
padded_polygon = np.array(padding.Execute(distance)[0])
|
||||
cv2.fillPoly(mask, [padded_polygon.astype(np.int32)], 1.0)
|
||||
|
||||
xmin = padded_polygon[:, 0].min()
|
||||
xmax = padded_polygon[:, 0].max()
|
||||
ymin = padded_polygon[:, 1].min()
|
||||
ymax = padded_polygon[:, 1].max()
|
||||
width = xmax - xmin + 1
|
||||
height = ymax - ymin + 1
|
||||
|
||||
polygon[:, 0] = polygon[:, 0] - xmin
|
||||
polygon[:, 1] = polygon[:, 1] - ymin
|
||||
|
||||
xs = np.broadcast_to(
|
||||
np.linspace(0, width - 1, num=width).reshape(1, width), (height, width)
|
||||
)
|
||||
ys = np.broadcast_to(
|
||||
np.linspace(0, height - 1, num=height).reshape(height, 1), (height, width)
|
||||
)
|
||||
|
||||
distance_map = np.zeros((polygon.shape[0], height, width), dtype=np.float32)
|
||||
for i in range(polygon.shape[0]):
|
||||
j = (i + 1) % polygon.shape[0]
|
||||
absolute_distance = self.distance(xs, ys, polygon[i], polygon[j])
|
||||
distance_map[i] = np.clip(absolute_distance / distance, 0, 1)
|
||||
distance_map = distance_map.min(axis=0)
|
||||
|
||||
xmin_valid = min(max(0, xmin), canvas.shape[1] - 1)
|
||||
xmax_valid = min(max(0, xmax), canvas.shape[1] - 1)
|
||||
ymin_valid = min(max(0, ymin), canvas.shape[0] - 1)
|
||||
ymax_valid = min(max(0, ymax), canvas.shape[0] - 1)
|
||||
canvas[ymin_valid : ymax_valid + 1, xmin_valid : xmax_valid + 1] = np.fmax(
|
||||
1
|
||||
- distance_map[
|
||||
ymin_valid - ymin : ymax_valid - ymax + height,
|
||||
xmin_valid - xmin : xmax_valid - xmax + width,
|
||||
],
|
||||
canvas[ymin_valid : ymax_valid + 1, xmin_valid : xmax_valid + 1],
|
||||
)
|
||||
|
||||
def distance(self, xs, ys, point_1, point_2):
|
||||
"""
|
||||
compute the distance from point to a line
|
||||
ys: coordinates in the first axis
|
||||
xs: coordinates in the second axis
|
||||
point_1, point_2: (x, y), the end of the line
|
||||
"""
|
||||
height, width = xs.shape[:2]
|
||||
square_distance_1 = np.square(xs - point_1[0]) + np.square(ys - point_1[1])
|
||||
square_distance_2 = np.square(xs - point_2[0]) + np.square(ys - point_2[1])
|
||||
square_distance = np.square(point_1[0] - point_2[0]) + np.square(
|
||||
point_1[1] - point_2[1]
|
||||
)
|
||||
|
||||
cosin = (square_distance - square_distance_1 - square_distance_2) / (
|
||||
2 * np.sqrt(square_distance_1 * square_distance_2)
|
||||
)
|
||||
square_sin = 1 - np.square(cosin)
|
||||
square_sin = np.nan_to_num(square_sin)
|
||||
|
||||
result = np.sqrt(
|
||||
square_distance_1 * square_distance_2 * square_sin / square_distance
|
||||
)
|
||||
result[cosin < 0] = np.sqrt(np.fmin(square_distance_1, square_distance_2))[
|
||||
cosin < 0
|
||||
]
|
||||
# self.extend_line(point_1, point_2, result)
|
||||
return result
|
||||
|
||||
def extend_line(self, point_1, point_2, result):
|
||||
ex_point_1 = (
|
||||
int(
|
||||
round(point_1[0] + (point_1[0] - point_2[0]) * (1 + self.shrink_ratio))
|
||||
),
|
||||
int(
|
||||
round(point_1[1] + (point_1[1] - point_2[1]) * (1 + self.shrink_ratio))
|
||||
),
|
||||
)
|
||||
cv2.line(
|
||||
result,
|
||||
tuple(ex_point_1),
|
||||
tuple(point_1),
|
||||
4096.0,
|
||||
1,
|
||||
lineType=cv2.LINE_AA,
|
||||
shift=0,
|
||||
)
|
||||
ex_point_2 = (
|
||||
int(
|
||||
round(point_2[0] + (point_2[0] - point_1[0]) * (1 + self.shrink_ratio))
|
||||
),
|
||||
int(
|
||||
round(point_2[1] + (point_2[1] - point_1[1]) * (1 + self.shrink_ratio))
|
||||
),
|
||||
)
|
||||
cv2.line(
|
||||
result,
|
||||
tuple(ex_point_2),
|
||||
tuple(point_2),
|
||||
4096.0,
|
||||
1,
|
||||
lineType=cv2.LINE_AA,
|
||||
shift=0,
|
||||
)
|
||||
return ex_point_1, ex_point_2
|
||||
@@ -0,0 +1,129 @@
|
||||
import numpy as np
|
||||
import cv2
|
||||
|
||||
|
||||
def shrink_polygon_py(polygon, shrink_ratio):
|
||||
"""
|
||||
对框进行缩放,返回去的比例为1/shrink_ratio 即可
|
||||
"""
|
||||
cx = polygon[:, 0].mean()
|
||||
cy = polygon[:, 1].mean()
|
||||
polygon[:, 0] = cx + (polygon[:, 0] - cx) * shrink_ratio
|
||||
polygon[:, 1] = cy + (polygon[:, 1] - cy) * shrink_ratio
|
||||
return polygon
|
||||
|
||||
|
||||
def shrink_polygon_pyclipper(polygon, shrink_ratio):
|
||||
from shapely.geometry import Polygon
|
||||
import pyclipper
|
||||
|
||||
polygon_shape = Polygon(polygon)
|
||||
distance = (
|
||||
polygon_shape.area * (1 - np.power(shrink_ratio, 2)) / polygon_shape.length
|
||||
)
|
||||
subject = [tuple(l) for l in polygon]
|
||||
padding = pyclipper.PyclipperOffset()
|
||||
padding.AddPath(subject, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON)
|
||||
shrunk = padding.Execute(-distance)
|
||||
if shrunk == []:
|
||||
shrunk = np.array(shrunk)
|
||||
else:
|
||||
shrunk = np.array(shrunk[0]).reshape(-1, 2)
|
||||
return shrunk
|
||||
|
||||
|
||||
class MakeShrinkMap:
|
||||
r"""
|
||||
Making binary mask from detection data with ICDAR format.
|
||||
Typically following the process of class `MakeICDARData`.
|
||||
"""
|
||||
|
||||
def __init__(self, min_text_size=8, shrink_ratio=0.4, shrink_type="pyclipper"):
|
||||
shrink_func_dict = {
|
||||
"py": shrink_polygon_py,
|
||||
"pyclipper": shrink_polygon_pyclipper,
|
||||
}
|
||||
self.shrink_func = shrink_func_dict[shrink_type]
|
||||
self.min_text_size = min_text_size
|
||||
self.shrink_ratio = shrink_ratio
|
||||
|
||||
def __call__(self, data: dict) -> dict:
|
||||
"""
|
||||
从scales中随机选择一个尺度,对图片和文本框进行缩放
|
||||
:param data: {'img':,'text_polys':,'texts':,'ignore_tags':}
|
||||
:return:
|
||||
"""
|
||||
image = data["img"]
|
||||
text_polys = data["text_polys"]
|
||||
ignore_tags = data["ignore_tags"]
|
||||
|
||||
h, w = image.shape[:2]
|
||||
text_polys, ignore_tags = self.validate_polygons(text_polys, ignore_tags, h, w)
|
||||
gt = np.zeros((h, w), dtype=np.float32)
|
||||
mask = np.ones((h, w), dtype=np.float32)
|
||||
for i in range(len(text_polys)):
|
||||
polygon = text_polys[i]
|
||||
height = max(polygon[:, 1]) - min(polygon[:, 1])
|
||||
width = max(polygon[:, 0]) - min(polygon[:, 0])
|
||||
if ignore_tags[i] or min(height, width) < self.min_text_size:
|
||||
cv2.fillPoly(mask, polygon.astype(np.int32)[np.newaxis, :, :], 0)
|
||||
ignore_tags[i] = True
|
||||
else:
|
||||
shrunk = self.shrink_func(polygon, self.shrink_ratio)
|
||||
if shrunk.size == 0:
|
||||
cv2.fillPoly(mask, polygon.astype(np.int32)[np.newaxis, :, :], 0)
|
||||
ignore_tags[i] = True
|
||||
continue
|
||||
cv2.fillPoly(gt, [shrunk.astype(np.int32)], 1)
|
||||
|
||||
data["shrink_map"] = gt
|
||||
data["shrink_mask"] = mask
|
||||
return data
|
||||
|
||||
def validate_polygons(self, polygons, ignore_tags, h, w):
|
||||
"""
|
||||
polygons (numpy.array, required): of shape (num_instances, num_points, 2)
|
||||
"""
|
||||
if len(polygons) == 0:
|
||||
return polygons, ignore_tags
|
||||
assert len(polygons) == len(ignore_tags)
|
||||
for polygon in polygons:
|
||||
polygon[:, 0] = np.clip(polygon[:, 0], 0, w - 1)
|
||||
polygon[:, 1] = np.clip(polygon[:, 1], 0, h - 1)
|
||||
|
||||
for i in range(len(polygons)):
|
||||
area = self.polygon_area(polygons[i])
|
||||
if abs(area) < 1:
|
||||
ignore_tags[i] = True
|
||||
if area > 0:
|
||||
polygons[i] = polygons[i][::-1, :]
|
||||
return polygons, ignore_tags
|
||||
|
||||
def polygon_area(self, polygon):
|
||||
return cv2.contourArea(polygon)
|
||||
# edge = 0
|
||||
# for i in range(polygon.shape[0]):
|
||||
# next_index = (i + 1) % polygon.shape[0]
|
||||
# edge += (polygon[next_index, 0] - polygon[i, 0]) * (polygon[next_index, 1] - polygon[i, 1])
|
||||
#
|
||||
# return edge / 2.
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from shapely.geometry import Polygon
|
||||
import pyclipper
|
||||
|
||||
polygon = np.array([[0, 0], [100, 10], [100, 100], [10, 90]])
|
||||
a = shrink_polygon_py(polygon, 0.4)
|
||||
print(a)
|
||||
print(shrink_polygon_py(a, 1 / 0.4))
|
||||
b = shrink_polygon_pyclipper(polygon, 0.4)
|
||||
print(b)
|
||||
poly = Polygon(b)
|
||||
distance = poly.area * 1.5 / poly.length
|
||||
offset = pyclipper.PyclipperOffset()
|
||||
offset.AddPath(b, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON)
|
||||
expanded = np.array(offset.Execute(distance))
|
||||
bounding_box = cv2.minAreaRect(expanded)
|
||||
points = cv2.boxPoints(bounding_box)
|
||||
print(points)
|
||||
@@ -0,0 +1,211 @@
|
||||
import random
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
# random crop algorithm similar to https://github.com/argman/EAST
|
||||
class EastRandomCropData:
|
||||
def __init__(
|
||||
self,
|
||||
size=(640, 640),
|
||||
max_tries=50,
|
||||
min_crop_side_ratio=0.1,
|
||||
require_original_image=False,
|
||||
keep_ratio=True,
|
||||
):
|
||||
self.size = size
|
||||
self.max_tries = max_tries
|
||||
self.min_crop_side_ratio = min_crop_side_ratio
|
||||
self.require_original_image = require_original_image
|
||||
self.keep_ratio = keep_ratio
|
||||
|
||||
def __call__(self, data: dict) -> dict:
|
||||
"""
|
||||
从scales中随机选择一个尺度,对图片和文本框进行缩放
|
||||
:param data: {'img':,'text_polys':,'texts':,'ignore_tags':}
|
||||
:return:
|
||||
"""
|
||||
im = data["img"]
|
||||
text_polys = data["text_polys"]
|
||||
ignore_tags = data["ignore_tags"]
|
||||
texts = data["texts"]
|
||||
all_care_polys = [text_polys[i] for i, tag in enumerate(ignore_tags) if not tag]
|
||||
# 计算crop区域
|
||||
crop_x, crop_y, crop_w, crop_h = self.crop_area(im, all_care_polys)
|
||||
# crop 图片 保持比例填充
|
||||
scale_w = self.size[0] / crop_w
|
||||
scale_h = self.size[1] / crop_h
|
||||
scale = min(scale_w, scale_h)
|
||||
h = int(crop_h * scale)
|
||||
w = int(crop_w * scale)
|
||||
if self.keep_ratio:
|
||||
if len(im.shape) == 3:
|
||||
padimg = np.zeros((self.size[1], self.size[0], im.shape[2]), im.dtype)
|
||||
else:
|
||||
padimg = np.zeros((self.size[1], self.size[0]), im.dtype)
|
||||
padimg[:h, :w] = cv2.resize(
|
||||
im[crop_y : crop_y + crop_h, crop_x : crop_x + crop_w], (w, h)
|
||||
)
|
||||
img = padimg
|
||||
else:
|
||||
img = cv2.resize(
|
||||
im[crop_y : crop_y + crop_h, crop_x : crop_x + crop_w], tuple(self.size)
|
||||
)
|
||||
# crop 文本框
|
||||
text_polys_crop = []
|
||||
ignore_tags_crop = []
|
||||
texts_crop = []
|
||||
for poly, text, tag in zip(text_polys, texts, ignore_tags):
|
||||
poly = ((poly - (crop_x, crop_y)) * scale).tolist()
|
||||
if not self.is_poly_outside_rect(poly, 0, 0, w, h):
|
||||
text_polys_crop.append(poly)
|
||||
ignore_tags_crop.append(tag)
|
||||
texts_crop.append(text)
|
||||
data["img"] = img
|
||||
data["text_polys"] = np.float32(text_polys_crop)
|
||||
data["ignore_tags"] = ignore_tags_crop
|
||||
data["texts"] = texts_crop
|
||||
return data
|
||||
|
||||
def is_poly_in_rect(self, poly, x, y, w, h):
|
||||
poly = np.array(poly)
|
||||
if poly[:, 0].min() < x or poly[:, 0].max() > x + w:
|
||||
return False
|
||||
if poly[:, 1].min() < y or poly[:, 1].max() > y + h:
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_poly_outside_rect(self, poly, x, y, w, h):
|
||||
poly = np.array(poly)
|
||||
if poly[:, 0].max() < x or poly[:, 0].min() > x + w:
|
||||
return True
|
||||
if poly[:, 1].max() < y or poly[:, 1].min() > y + h:
|
||||
return True
|
||||
return False
|
||||
|
||||
def split_regions(self, axis):
|
||||
regions = []
|
||||
min_axis = 0
|
||||
for i in range(1, axis.shape[0]):
|
||||
if axis[i] != axis[i - 1] + 1:
|
||||
region = axis[min_axis:i]
|
||||
min_axis = i
|
||||
regions.append(region)
|
||||
return regions
|
||||
|
||||
def random_select(self, axis, max_size):
|
||||
xx = np.random.choice(axis, size=2)
|
||||
xmin = np.min(xx)
|
||||
xmax = np.max(xx)
|
||||
xmin = np.clip(xmin, 0, max_size - 1)
|
||||
xmax = np.clip(xmax, 0, max_size - 1)
|
||||
return xmin, xmax
|
||||
|
||||
def region_wise_random_select(self, regions, max_size):
|
||||
selected_index = list(np.random.choice(len(regions), 2))
|
||||
selected_values = []
|
||||
for index in selected_index:
|
||||
axis = regions[index]
|
||||
xx = int(np.random.choice(axis, size=1))
|
||||
selected_values.append(xx)
|
||||
xmin = min(selected_values)
|
||||
xmax = max(selected_values)
|
||||
return xmin, xmax
|
||||
|
||||
def crop_area(self, im, text_polys):
|
||||
h, w = im.shape[:2]
|
||||
h_array = np.zeros(h, dtype=np.int32)
|
||||
w_array = np.zeros(w, dtype=np.int32)
|
||||
for points in text_polys:
|
||||
points = np.round(points, decimals=0).astype(np.int32)
|
||||
minx = np.min(points[:, 0])
|
||||
maxx = np.max(points[:, 0])
|
||||
w_array[minx:maxx] = 1
|
||||
miny = np.min(points[:, 1])
|
||||
maxy = np.max(points[:, 1])
|
||||
h_array[miny:maxy] = 1
|
||||
# ensure the cropped area not across a text
|
||||
h_axis = np.where(h_array == 0)[0]
|
||||
w_axis = np.where(w_array == 0)[0]
|
||||
|
||||
if len(h_axis) == 0 or len(w_axis) == 0:
|
||||
return 0, 0, w, h
|
||||
|
||||
h_regions = self.split_regions(h_axis)
|
||||
w_regions = self.split_regions(w_axis)
|
||||
|
||||
for i in range(self.max_tries):
|
||||
if len(w_regions) > 1:
|
||||
xmin, xmax = self.region_wise_random_select(w_regions, w)
|
||||
else:
|
||||
xmin, xmax = self.random_select(w_axis, w)
|
||||
if len(h_regions) > 1:
|
||||
ymin, ymax = self.region_wise_random_select(h_regions, h)
|
||||
else:
|
||||
ymin, ymax = self.random_select(h_axis, h)
|
||||
|
||||
if (
|
||||
xmax - xmin < self.min_crop_side_ratio * w
|
||||
or ymax - ymin < self.min_crop_side_ratio * h
|
||||
):
|
||||
# area too small
|
||||
continue
|
||||
num_poly_in_rect = 0
|
||||
for poly in text_polys:
|
||||
if not self.is_poly_outside_rect(
|
||||
poly, xmin, ymin, xmax - xmin, ymax - ymin
|
||||
):
|
||||
num_poly_in_rect += 1
|
||||
break
|
||||
|
||||
if num_poly_in_rect > 0:
|
||||
return xmin, ymin, xmax - xmin, ymax - ymin
|
||||
|
||||
return 0, 0, w, h
|
||||
|
||||
|
||||
class PSERandomCrop:
|
||||
def __init__(self, size):
|
||||
self.size = size
|
||||
|
||||
def __call__(self, data):
|
||||
imgs = data["imgs"]
|
||||
|
||||
h, w = imgs[0].shape[0:2]
|
||||
th, tw = self.size
|
||||
if w == tw and h == th:
|
||||
return imgs
|
||||
|
||||
# label中存在文本实例,并且按照概率进行裁剪,使用threshold_label_map控制
|
||||
if np.max(imgs[2]) > 0 and random.random() > 3 / 8:
|
||||
# 文本实例的左上角点
|
||||
tl = np.min(np.where(imgs[2] > 0), axis=1) - self.size
|
||||
tl[tl < 0] = 0
|
||||
# 文本实例的右下角点
|
||||
br = np.max(np.where(imgs[2] > 0), axis=1) - self.size
|
||||
br[br < 0] = 0
|
||||
# 保证选到右下角点时,有足够的距离进行crop
|
||||
br[0] = min(br[0], h - th)
|
||||
br[1] = min(br[1], w - tw)
|
||||
|
||||
for _ in range(50000):
|
||||
i = random.randint(tl[0], br[0])
|
||||
j = random.randint(tl[1], br[1])
|
||||
# 保证shrink_label_map有文本
|
||||
if imgs[1][i : i + th, j : j + tw].sum() <= 0:
|
||||
continue
|
||||
else:
|
||||
break
|
||||
else:
|
||||
i = random.randint(0, h - th)
|
||||
j = random.randint(0, w - tw)
|
||||
|
||||
# return i, j, th, tw
|
||||
for idx in range(len(imgs)):
|
||||
if len(imgs[idx].shape) == 3:
|
||||
imgs[idx] = imgs[idx][i : i + th, j : j + tw, :]
|
||||
else:
|
||||
imgs[idx] = imgs[idx][i : i + th, j : j + tw]
|
||||
data["imgs"] = imgs
|
||||
return data
|
||||
@@ -0,0 +1,21 @@
|
||||
name: dbnet
|
||||
channels:
|
||||
- conda-forge
|
||||
- defaults
|
||||
dependencies:
|
||||
- anyconfig==0.9.10
|
||||
- future==0.18.2
|
||||
- imgaug==0.4.0
|
||||
- matplotlib==3.1.2
|
||||
- numpy==1.17.4
|
||||
- opencv
|
||||
- pyclipper
|
||||
- PyYAML==5.2
|
||||
- scikit-image==0.16.2
|
||||
- Shapely==1.6.4
|
||||
- tensorboard=2
|
||||
- tqdm==4.40.1
|
||||
- ipython
|
||||
- pip
|
||||
- pip:
|
||||
- polygon3
|
||||
@@ -0,0 +1 @@
|
||||
CUDA_VISIBLE_DEVICES=0 python3 tools/eval.py --model_path ''
|
||||
@@ -0,0 +1,17 @@
|
||||
#Only use if your file names of the images and txts are identical
|
||||
rm ./datasets/train_img.txt
|
||||
rm ./datasets/train_gt.txt
|
||||
rm ./datasets/test_img.txt
|
||||
rm ./datasets/test_gt.txt
|
||||
rm ./datasets/train.txt
|
||||
rm ./datasets/test.txt
|
||||
ls ./datasets/train/img/*.jpg > ./datasets/train_img.txt
|
||||
ls ./datasets/train/gt/*.txt > ./datasets/train_gt.txt
|
||||
ls ./datasets/test/img/*.jpg > ./datasets/test_img.txt
|
||||
ls ./datasets/test/gt/*.txt > ./datasets/test_gt.txt
|
||||
paste ./datasets/train_img.txt ./datasets/train_gt.txt > ./datasets/train.txt
|
||||
paste ./datasets/test_img.txt ./datasets/test_gt.txt > ./datasets/test.txt
|
||||
rm ./datasets/train_img.txt
|
||||
rm ./datasets/train_gt.txt
|
||||
rm ./datasets/test_img.txt
|
||||
rm ./datasets/test_gt.txt
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# @Time : 2019/8/23 21:55
|
||||
# @Author : zhoujun
|
||||
import copy
|
||||
from .model import Model
|
||||
from .losses import build_loss
|
||||
|
||||
__all__ = ["build_loss", "build_model"]
|
||||
support_model = ["Model"]
|
||||
|
||||
|
||||
def build_model(config):
|
||||
"""
|
||||
get architecture model class
|
||||
"""
|
||||
copy_config = copy.deepcopy(config)
|
||||
arch_type = copy_config.pop("type")
|
||||
assert (
|
||||
arch_type in support_model
|
||||
), f"{arch_type} is not developed yet!, only {support_model} are support now"
|
||||
arch_model = eval(arch_type)(copy_config)
|
||||
return arch_model
|
||||
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# @Time : 2019/8/23 21:54
|
||||
# @Author : zhoujun
|
||||
|
||||
from .resnet import *
|
||||
|
||||
__all__ = ["build_backbone"]
|
||||
|
||||
support_backbone = [
|
||||
"resnet18",
|
||||
"deformable_resnet18",
|
||||
"deformable_resnet50",
|
||||
"resnet50",
|
||||
"resnet34",
|
||||
"resnet101",
|
||||
"resnet152",
|
||||
]
|
||||
|
||||
|
||||
def build_backbone(backbone_name, **kwargs):
|
||||
assert (
|
||||
backbone_name in support_backbone
|
||||
), f"all support backbone is {support_backbone}"
|
||||
backbone = eval(backbone_name)(**kwargs)
|
||||
return backbone
|
||||
@@ -0,0 +1,366 @@
|
||||
import math
|
||||
import paddle
|
||||
from paddle import nn
|
||||
|
||||
BatchNorm2d = nn.BatchNorm2D
|
||||
|
||||
__all__ = [
|
||||
"ResNet",
|
||||
"resnet18",
|
||||
"resnet34",
|
||||
"resnet50",
|
||||
"resnet101",
|
||||
"deformable_resnet18",
|
||||
"deformable_resnet50",
|
||||
"resnet152",
|
||||
]
|
||||
|
||||
model_urls = {
|
||||
"resnet18": "https://download.pytorch.org/models/resnet18-5c106cde.pth",
|
||||
"resnet34": "https://download.pytorch.org/models/resnet34-333f7ec4.pth",
|
||||
"resnet50": "https://download.pytorch.org/models/resnet50-19c8e357.pth",
|
||||
"resnet101": "https://download.pytorch.org/models/resnet101-5d3b4d8f.pth",
|
||||
"resnet152": "https://download.pytorch.org/models/resnet152-b121ed2d.pth",
|
||||
}
|
||||
|
||||
|
||||
def constant_init(module, constant, bias=0):
|
||||
module.weight = paddle.create_parameter(
|
||||
shape=module.weight.shape,
|
||||
dtype="float32",
|
||||
default_initializer=paddle.nn.initializer.Constant(constant),
|
||||
)
|
||||
if hasattr(module, "bias"):
|
||||
module.bias = paddle.create_parameter(
|
||||
shape=module.bias.shape,
|
||||
dtype="float32",
|
||||
default_initializer=paddle.nn.initializer.Constant(bias),
|
||||
)
|
||||
|
||||
|
||||
def conv3x3(in_planes, out_planes, stride=1):
|
||||
"""3x3 convolution with padding"""
|
||||
return nn.Conv2D(
|
||||
in_planes, out_planes, kernel_size=3, stride=stride, padding=1, bias_attr=False
|
||||
)
|
||||
|
||||
|
||||
class BasicBlock(nn.Layer):
|
||||
expansion = 1
|
||||
|
||||
def __init__(self, inplanes, planes, stride=1, downsample=None, dcn=None):
|
||||
super(BasicBlock, self).__init__()
|
||||
self.with_dcn = dcn is not None
|
||||
self.conv1 = conv3x3(inplanes, planes, stride)
|
||||
self.bn1 = BatchNorm2d(planes, momentum=0.1)
|
||||
self.relu = nn.ReLU()
|
||||
self.with_modulated_dcn = False
|
||||
if not self.with_dcn:
|
||||
self.conv2 = nn.Conv2D(
|
||||
planes, planes, kernel_size=3, padding=1, bias_attr=False
|
||||
)
|
||||
else:
|
||||
from paddle.vision.ops import DeformConv2D
|
||||
|
||||
deformable_groups = dcn.get("deformable_groups", 1)
|
||||
offset_channels = 18
|
||||
self.conv2_offset = nn.Conv2D(
|
||||
planes, deformable_groups * offset_channels, kernel_size=3, padding=1
|
||||
)
|
||||
self.conv2 = DeformConv2D(
|
||||
planes, planes, kernel_size=3, padding=1, bias_attr=False
|
||||
)
|
||||
self.bn2 = BatchNorm2d(planes, momentum=0.1)
|
||||
self.downsample = downsample
|
||||
self.stride = stride
|
||||
|
||||
def forward(self, x):
|
||||
residual = x
|
||||
|
||||
out = self.conv1(x)
|
||||
out = self.bn1(out)
|
||||
out = self.relu(out)
|
||||
|
||||
# out = self.conv2(out)
|
||||
if not self.with_dcn:
|
||||
out = self.conv2(out)
|
||||
else:
|
||||
offset = self.conv2_offset(out)
|
||||
out = self.conv2(out, offset)
|
||||
out = self.bn2(out)
|
||||
|
||||
if self.downsample is not None:
|
||||
residual = self.downsample(x)
|
||||
|
||||
out += residual
|
||||
out = self.relu(out)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
class Bottleneck(nn.Layer):
|
||||
expansion = 4
|
||||
|
||||
def __init__(self, inplanes, planes, stride=1, downsample=None, dcn=None):
|
||||
super(Bottleneck, self).__init__()
|
||||
self.with_dcn = dcn is not None
|
||||
self.conv1 = nn.Conv2D(inplanes, planes, kernel_size=1, bias_attr=False)
|
||||
self.bn1 = BatchNorm2d(planes, momentum=0.1)
|
||||
self.with_modulated_dcn = False
|
||||
if not self.with_dcn:
|
||||
self.conv2 = nn.Conv2D(
|
||||
planes, planes, kernel_size=3, stride=stride, padding=1, bias_attr=False
|
||||
)
|
||||
else:
|
||||
deformable_groups = dcn.get("deformable_groups", 1)
|
||||
from paddle.vision.ops import DeformConv2D
|
||||
|
||||
offset_channels = 18
|
||||
self.conv2_offset = nn.Conv2D(
|
||||
planes,
|
||||
deformable_groups * offset_channels,
|
||||
stride=stride,
|
||||
kernel_size=3,
|
||||
padding=1,
|
||||
)
|
||||
self.conv2 = DeformConv2D(
|
||||
planes, planes, kernel_size=3, padding=1, stride=stride, bias_attr=False
|
||||
)
|
||||
self.bn2 = BatchNorm2d(planes, momentum=0.1)
|
||||
self.conv3 = nn.Conv2D(planes, planes * 4, kernel_size=1, bias_attr=False)
|
||||
self.bn3 = BatchNorm2d(planes * 4, momentum=0.1)
|
||||
self.relu = nn.ReLU()
|
||||
self.downsample = downsample
|
||||
self.stride = stride
|
||||
self.dcn = dcn
|
||||
self.with_dcn = dcn is not None
|
||||
|
||||
def forward(self, x):
|
||||
residual = x
|
||||
|
||||
out = self.conv1(x)
|
||||
out = self.bn1(out)
|
||||
out = self.relu(out)
|
||||
|
||||
# out = self.conv2(out)
|
||||
if not self.with_dcn:
|
||||
out = self.conv2(out)
|
||||
else:
|
||||
offset = self.conv2_offset(out)
|
||||
out = self.conv2(out, offset)
|
||||
out = self.bn2(out)
|
||||
out = self.relu(out)
|
||||
|
||||
out = self.conv3(out)
|
||||
out = self.bn3(out)
|
||||
|
||||
if self.downsample is not None:
|
||||
residual = self.downsample(x)
|
||||
|
||||
out += residual
|
||||
out = self.relu(out)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
class ResNet(nn.Layer):
|
||||
def __init__(self, block, layers, in_channels=3, dcn=None):
|
||||
self.dcn = dcn
|
||||
self.inplanes = 64
|
||||
super(ResNet, self).__init__()
|
||||
self.out_channels = []
|
||||
self.conv1 = nn.Conv2D(
|
||||
in_channels, 64, kernel_size=7, stride=2, padding=3, bias_attr=False
|
||||
)
|
||||
self.bn1 = BatchNorm2d(64, momentum=0.1)
|
||||
self.relu = nn.ReLU()
|
||||
self.maxpool = nn.MaxPool2D(kernel_size=3, stride=2, padding=1)
|
||||
self.layer1 = self._make_layer(block, 64, layers[0])
|
||||
self.layer2 = self._make_layer(block, 128, layers[1], stride=2, dcn=dcn)
|
||||
self.layer3 = self._make_layer(block, 256, layers[2], stride=2, dcn=dcn)
|
||||
self.layer4 = self._make_layer(block, 512, layers[3], stride=2, dcn=dcn)
|
||||
|
||||
if self.dcn is not None:
|
||||
for m in self.modules():
|
||||
if isinstance(m, Bottleneck) or isinstance(m, BasicBlock):
|
||||
if hasattr(m, "conv2_offset"):
|
||||
constant_init(m.conv2_offset, 0)
|
||||
|
||||
def _make_layer(self, block, planes, blocks, stride=1, dcn=None):
|
||||
downsample = None
|
||||
if stride != 1 or self.inplanes != planes * block.expansion:
|
||||
downsample = nn.Sequential(
|
||||
nn.Conv2D(
|
||||
self.inplanes,
|
||||
planes * block.expansion,
|
||||
kernel_size=1,
|
||||
stride=stride,
|
||||
bias_attr=False,
|
||||
),
|
||||
BatchNorm2d(planes * block.expansion, momentum=0.1),
|
||||
)
|
||||
|
||||
layers = []
|
||||
layers.append(block(self.inplanes, planes, stride, downsample, dcn=dcn))
|
||||
self.inplanes = planes * block.expansion
|
||||
for i in range(1, blocks):
|
||||
layers.append(block(self.inplanes, planes, dcn=dcn))
|
||||
self.out_channels.append(planes * block.expansion)
|
||||
return nn.Sequential(*layers)
|
||||
|
||||
def forward(self, x):
|
||||
x = self.conv1(x)
|
||||
x = self.bn1(x)
|
||||
x = self.relu(x)
|
||||
x = self.maxpool(x)
|
||||
|
||||
x2 = self.layer1(x)
|
||||
x3 = self.layer2(x2)
|
||||
x4 = self.layer3(x3)
|
||||
x5 = self.layer4(x4)
|
||||
|
||||
return x2, x3, x4, x5
|
||||
|
||||
|
||||
def load_torch_params(paddle_model, torch_patams):
|
||||
paddle_params = paddle_model.state_dict()
|
||||
|
||||
fc_names = ["classifier"]
|
||||
for key, torch_value in torch_patams.items():
|
||||
if "num_batches_tracked" in key:
|
||||
continue
|
||||
key = (
|
||||
key.replace("running_var", "_variance")
|
||||
.replace("running_mean", "_mean")
|
||||
.replace("module.", "")
|
||||
)
|
||||
torch_value = torch_value.detach().cpu().numpy()
|
||||
if key in paddle_params:
|
||||
flag = [i in key for i in fc_names]
|
||||
if any(flag) and "weight" in key: # ignore bias
|
||||
new_shape = [1, 0] + list(range(2, torch_value.ndim))
|
||||
print(
|
||||
f"name: {key}, ori shape: {torch_value.shape}, new shape: {torch_value.transpose(new_shape).shape}"
|
||||
)
|
||||
torch_value = torch_value.transpose(new_shape)
|
||||
paddle_params[key] = torch_value
|
||||
else:
|
||||
print(f"{key} not in paddle")
|
||||
paddle_model.set_state_dict(paddle_params)
|
||||
|
||||
|
||||
def load_models(model, model_name):
|
||||
import torch.utils.model_zoo as model_zoo
|
||||
|
||||
torch_patams = model_zoo.load_url(model_urls[model_name])
|
||||
load_torch_params(model, torch_patams)
|
||||
|
||||
|
||||
def resnet18(pretrained=True, **kwargs):
|
||||
"""Constructs a ResNet-18 model.
|
||||
Args:
|
||||
pretrained (bool): If True, returns a model pre-trained on ImageNet
|
||||
"""
|
||||
model = ResNet(BasicBlock, [2, 2, 2, 2], **kwargs)
|
||||
if pretrained:
|
||||
assert (
|
||||
kwargs.get("in_channels", 3) == 3
|
||||
), "in_channels must be 3 when pretrained is True"
|
||||
print("load from imagenet")
|
||||
load_models(model, "resnet18")
|
||||
return model
|
||||
|
||||
|
||||
def deformable_resnet18(pretrained=True, **kwargs):
|
||||
"""Constructs a ResNet-18 model.
|
||||
Args:
|
||||
pretrained (bool): If True, returns a model pre-trained on ImageNet
|
||||
"""
|
||||
model = ResNet(BasicBlock, [2, 2, 2, 2], dcn=dict(deformable_groups=1), **kwargs)
|
||||
if pretrained:
|
||||
assert (
|
||||
kwargs.get("in_channels", 3) == 3
|
||||
), "in_channels must be 3 when pretrained is True"
|
||||
print("load from imagenet")
|
||||
model.load_state_dict(model_zoo.load_url(model_urls["resnet18"]), strict=False)
|
||||
return model
|
||||
|
||||
|
||||
def resnet34(pretrained=True, **kwargs):
|
||||
"""Constructs a ResNet-34 model.
|
||||
Args:
|
||||
pretrained (bool): If True, returns a model pre-trained on ImageNet
|
||||
"""
|
||||
model = ResNet(BasicBlock, [3, 4, 6, 3], **kwargs)
|
||||
if pretrained:
|
||||
assert (
|
||||
kwargs.get("in_channels", 3) == 3
|
||||
), "in_channels must be 3 when pretrained is True"
|
||||
model.load_state_dict(model_zoo.load_url(model_urls["resnet34"]), strict=False)
|
||||
return model
|
||||
|
||||
|
||||
def resnet50(pretrained=True, **kwargs):
|
||||
"""Constructs a ResNet-50 model.
|
||||
Args:
|
||||
pretrained (bool): If True, returns a model pre-trained on ImageNet
|
||||
"""
|
||||
model = ResNet(Bottleneck, [3, 4, 6, 3], **kwargs)
|
||||
if pretrained:
|
||||
assert (
|
||||
kwargs.get("in_channels", 3) == 3
|
||||
), "in_channels must be 3 when pretrained is True"
|
||||
load_models(model, "resnet50")
|
||||
return model
|
||||
|
||||
|
||||
def deformable_resnet50(pretrained=True, **kwargs):
|
||||
"""Constructs a ResNet-50 model with deformable conv.
|
||||
Args:
|
||||
pretrained (bool): If True, returns a model pre-trained on ImageNet
|
||||
"""
|
||||
model = ResNet(Bottleneck, [3, 4, 6, 3], dcn=dict(deformable_groups=1), **kwargs)
|
||||
if pretrained:
|
||||
assert (
|
||||
kwargs.get("in_channels", 3) == 3
|
||||
), "in_channels must be 3 when pretrained is True"
|
||||
model.load_state_dict(model_zoo.load_url(model_urls["resnet50"]), strict=False)
|
||||
return model
|
||||
|
||||
|
||||
def resnet101(pretrained=True, **kwargs):
|
||||
"""Constructs a ResNet-101 model.
|
||||
Args:
|
||||
pretrained (bool): If True, returns a model pre-trained on ImageNet
|
||||
"""
|
||||
model = ResNet(Bottleneck, [3, 4, 23, 3], **kwargs)
|
||||
if pretrained:
|
||||
assert (
|
||||
kwargs.get("in_channels", 3) == 3
|
||||
), "in_channels must be 3 when pretrained is True"
|
||||
model.load_state_dict(model_zoo.load_url(model_urls["resnet101"]), strict=False)
|
||||
return model
|
||||
|
||||
|
||||
def resnet152(pretrained=True, **kwargs):
|
||||
"""Constructs a ResNet-152 model.
|
||||
Args:
|
||||
pretrained (bool): If True, returns a model pre-trained on ImageNet
|
||||
"""
|
||||
model = ResNet(Bottleneck, [3, 8, 36, 3], **kwargs)
|
||||
if pretrained:
|
||||
assert (
|
||||
kwargs.get("in_channels", 3) == 3
|
||||
), "in_channels must be 3 when pretrained is True"
|
||||
model.load_state_dict(model_zoo.load_url(model_urls["resnet152"]), strict=False)
|
||||
return model
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
x = paddle.zeros([2, 3, 640, 640])
|
||||
net = resnet50(pretrained=True)
|
||||
y = net(x)
|
||||
for u in y:
|
||||
print(u.shape)
|
||||
|
||||
print(net.out_channels)
|
||||
@@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# @Time : 2019/12/6 11:19
|
||||
# @Author : zhoujun
|
||||
from paddle import nn
|
||||
|
||||
|
||||
class ConvBnRelu(nn.Layer):
|
||||
def __init__(
|
||||
self,
|
||||
in_channels,
|
||||
out_channels,
|
||||
kernel_size,
|
||||
stride=1,
|
||||
padding=0,
|
||||
dilation=1,
|
||||
groups=1,
|
||||
bias=True,
|
||||
padding_mode="zeros",
|
||||
inplace=True,
|
||||
):
|
||||
super().__init__()
|
||||
self.conv = nn.Conv2D(
|
||||
in_channels=in_channels,
|
||||
out_channels=out_channels,
|
||||
kernel_size=kernel_size,
|
||||
stride=stride,
|
||||
padding=padding,
|
||||
dilation=dilation,
|
||||
groups=groups,
|
||||
bias_attr=bias,
|
||||
padding_mode=padding_mode,
|
||||
)
|
||||
self.bn = nn.BatchNorm2D(out_channels)
|
||||
self.relu = nn.ReLU()
|
||||
|
||||
def forward(self, x):
|
||||
x = self.conv(x)
|
||||
x = self.bn(x)
|
||||
x = self.relu(x)
|
||||
return x
|
||||
@@ -0,0 +1,132 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# @Time : 2019/12/4 14:54
|
||||
# @Author : zhoujun
|
||||
import paddle
|
||||
from paddle import nn, ParamAttr
|
||||
|
||||
|
||||
class DBHead(nn.Layer):
|
||||
def __init__(self, in_channels, out_channels, k=50):
|
||||
super().__init__()
|
||||
self.k = k
|
||||
self.binarize = nn.Sequential(
|
||||
nn.Conv2D(
|
||||
in_channels,
|
||||
in_channels // 4,
|
||||
3,
|
||||
padding=1,
|
||||
weight_attr=ParamAttr(initializer=nn.initializer.KaimingNormal()),
|
||||
),
|
||||
nn.BatchNorm2D(
|
||||
in_channels // 4,
|
||||
weight_attr=ParamAttr(initializer=nn.initializer.Constant(1)),
|
||||
bias_attr=ParamAttr(initializer=nn.initializer.Constant(1e-4)),
|
||||
),
|
||||
nn.ReLU(),
|
||||
nn.Conv2DTranspose(
|
||||
in_channels // 4,
|
||||
in_channels // 4,
|
||||
2,
|
||||
2,
|
||||
weight_attr=ParamAttr(initializer=nn.initializer.KaimingNormal()),
|
||||
),
|
||||
nn.BatchNorm2D(
|
||||
in_channels // 4,
|
||||
weight_attr=ParamAttr(initializer=nn.initializer.Constant(1)),
|
||||
bias_attr=ParamAttr(initializer=nn.initializer.Constant(1e-4)),
|
||||
),
|
||||
nn.ReLU(),
|
||||
nn.Conv2DTranspose(
|
||||
in_channels // 4, 1, 2, 2, weight_attr=nn.initializer.KaimingNormal()
|
||||
),
|
||||
nn.Sigmoid(),
|
||||
)
|
||||
|
||||
self.thresh = self._init_thresh(in_channels)
|
||||
|
||||
def forward(self, x):
|
||||
shrink_maps = self.binarize(x)
|
||||
threshold_maps = self.thresh(x)
|
||||
if self.training:
|
||||
binary_maps = self.step_function(shrink_maps, threshold_maps)
|
||||
y = paddle.concat((shrink_maps, threshold_maps, binary_maps), axis=1)
|
||||
else:
|
||||
y = paddle.concat((shrink_maps, threshold_maps), axis=1)
|
||||
return y
|
||||
|
||||
def _init_thresh(self, inner_channels, serial=False, smooth=False, bias=False):
|
||||
in_channels = inner_channels
|
||||
if serial:
|
||||
in_channels += 1
|
||||
self.thresh = nn.Sequential(
|
||||
nn.Conv2D(
|
||||
in_channels,
|
||||
inner_channels // 4,
|
||||
3,
|
||||
padding=1,
|
||||
bias_attr=bias,
|
||||
weight_attr=ParamAttr(initializer=nn.initializer.KaimingNormal()),
|
||||
),
|
||||
nn.BatchNorm2D(
|
||||
inner_channels // 4,
|
||||
weight_attr=ParamAttr(initializer=nn.initializer.Constant(1)),
|
||||
bias_attr=ParamAttr(initializer=nn.initializer.Constant(1e-4)),
|
||||
),
|
||||
nn.ReLU(),
|
||||
self._init_upsample(
|
||||
inner_channels // 4, inner_channels // 4, smooth=smooth, bias=bias
|
||||
),
|
||||
nn.BatchNorm2D(
|
||||
inner_channels // 4,
|
||||
weight_attr=ParamAttr(initializer=nn.initializer.Constant(1)),
|
||||
bias_attr=ParamAttr(initializer=nn.initializer.Constant(1e-4)),
|
||||
),
|
||||
nn.ReLU(),
|
||||
self._init_upsample(inner_channels // 4, 1, smooth=smooth, bias=bias),
|
||||
nn.Sigmoid(),
|
||||
)
|
||||
return self.thresh
|
||||
|
||||
def _init_upsample(self, in_channels, out_channels, smooth=False, bias=False):
|
||||
if smooth:
|
||||
inter_out_channels = out_channels
|
||||
if out_channels == 1:
|
||||
inter_out_channels = in_channels
|
||||
module_list = [
|
||||
nn.Upsample(scale_factor=2, mode="nearest"),
|
||||
nn.Conv2D(
|
||||
in_channels,
|
||||
inter_out_channels,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
bias_attr=bias,
|
||||
weight_attr=ParamAttr(initializer=nn.initializer.KaimingNormal()),
|
||||
),
|
||||
]
|
||||
if out_channels == 1:
|
||||
module_list.append(
|
||||
nn.Conv2D(
|
||||
in_channels,
|
||||
out_channels,
|
||||
kernel_size=1,
|
||||
stride=1,
|
||||
padding=1,
|
||||
bias_attr=True,
|
||||
weight_attr=ParamAttr(
|
||||
initializer=nn.initializer.KaimingNormal()
|
||||
),
|
||||
)
|
||||
)
|
||||
return nn.Sequential(module_list)
|
||||
else:
|
||||
return nn.Conv2DTranspose(
|
||||
in_channels,
|
||||
out_channels,
|
||||
2,
|
||||
2,
|
||||
weight_attr=ParamAttr(initializer=nn.initializer.KaimingNormal()),
|
||||
)
|
||||
|
||||
def step_function(self, x, y):
|
||||
return paddle.reciprocal(1 + paddle.exp(-self.k * (x - y)))
|
||||
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# @Time : 2020/6/5 11:35
|
||||
# @Author : zhoujun
|
||||
from .DBHead import DBHead
|
||||
|
||||
__all__ = ["build_head"]
|
||||
support_head = ["DBHead"]
|
||||
|
||||
|
||||
def build_head(head_name, **kwargs):
|
||||
assert head_name in support_head, f"all support head is {support_head}"
|
||||
head = eval(head_name)(**kwargs)
|
||||
return head
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user